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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "siesa-agents",
3
- "version": "2.1.72-qa.13",
3
+ "version": "2.1.72-qa.15",
4
4
  "description": "Paquete para instalar y configurar agentes SIESA en tu proyecto",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -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). El
939
- agente ya tiene en memoria las Secciones I→VI del megaprompt — serializarlas a YAML con esta
940
- estructura:
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 V6.0 → AgileTest/Jira
2
+ bmad_to_agiletest.py — Parser + Cargador de artefactos BMAD V7.8 → AgileTest/Jira
3
3
 
4
- Lee un archivo bmad-v6-*-test-design.md (documento unificado BMAD V6.0) y extrae:
5
- - Stories desde Seccion I (Gatekeeper)
6
- - FACs Gherkin desde Seccion II (Features & FAC)
7
- - Test Cases desde Seccion IV (Matriz 360°)
8
- - Traceability desde Apendice
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 mi-archivo.md --dry-run # Archivo custom
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: 09 abril 2026 (soporte BMAD V6.0)
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 V6.0 (ruta relativa al script)
57
- 'input_file': 'bmad-v6-segment-test-design.md',
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
- # Parser: Metadata del documento
140
+ # Loader: test-design.yml (BMAD V7.8) via yaml.safe_load
138
141
  # ============================================================
139
142
 
140
- def parse_metadata(content: str) -> dict:
141
- """Extrae metadata del header del documento BMAD V6.0."""
142
- meta = {'feature': '', 'feature_code': '', 'date': '', 'author': ''}
143
-
144
- m = re.search(r'\*\*M[oó]dulo/Feature:\*\*\s*(.+)', content)
145
- if m:
146
- meta['feature'] = m.group(1).strip()
147
-
148
- m = re.search(r'\*\*Feature Code:\*\*\s*(.+)', content)
149
- if m:
150
- meta['feature_code'] = m.group(1).strip()
151
-
152
- m = re.search(r'\*\*Fecha del Dise[nñ]o:\*\*\s*(.+)', content)
153
- if not m:
154
- m = re.search(r'\*\*Fecha.*?:\*\*\s*(.+)', content)
155
- if m:
156
- meta['date'] = m.group(1).strip()
157
-
158
- return meta
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
- # Parser: Seccion I — Gatekeeper (Stories)
178
+ # Builder: gatekeeper[] -> Stories
162
179
  # ============================================================
163
180
 
164
- def parse_stories(content: str, feature_code: str) -> list:
165
- """Extrae stories de la tabla del Gatekeeper (Seccion I).
181
+ def build_stories(design: dict) -> list:
182
+ """Construye Stories desde `gatekeeper[]` del YAML V7.8.
166
183
 
167
- Soporta dos formatos de story_id:
168
- - Clasico BMAD: FEATURE-E001-S001 (ej: SEGM-SEGMT-E001-S001)
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
- # Buscar seccion I — tolerar distintos terminadores de sección
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
- cols = [c.strip() for c in line.split('|')[1:-1]]
188
- if len(cols) < 3:
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
- title = cols[1].strip()
201
- # Layout del gatekeeper:
202
- # - 4 cols (clasico/qp): ID | Titulo | Clasificacion | Justificacion
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 is_classic:
213
- epic_match = re.search(r'(.*-E\d+)', story_id)
214
- epic_id = epic_match.group(1) if epic_match else ''
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
- epic_num_match = re.match(r'(?:Story\s+)?(\d+)\.\d+', story_id)
217
- epic_id = f'Epic {epic_num_match.group(1)}' if epic_num_match else ''
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 # Lógica / Logica
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
- # Parser: Seccion II FACs Gherkin
234
+ # Builder: features[].fac[] -> FACs Gherkin
249
235
  # ============================================================
250
236
 
251
- def parse_facs(content: str) -> dict:
252
- """Extrae FAC Gherkin de los bloques ```gherkin. Retorna dict: fac_id -> texto.
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
- gherkin_blocks = re.findall(r'```gherkin\s*\n(.*?)```', content, re.DOTALL)
262
-
263
- # ID embebido en el titulo del Scenario. Dos convenciones:
264
- # - clasico: 'Scenario: F1-FAC-001 ...'
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
- if current_id:
303
- current_lines.append(line)
304
- flush()
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
- # Parser: Seccion IV Matriz 360° (Test Cases)
253
+ # Builder: test_matrix[] -> Test Cases (con steps atomicos)
310
254
  # ============================================================
311
255
 
312
- def parse_matrix_row(cols: list) -> TestCase:
313
- """Parsea una fila de la matriz 360° en un TestCase.
256
+ def build_test_cases(design: dict) -> list:
257
+ """Construye Test Cases desde `test_matrix[]` (V7.8).
314
258
 
315
- Soporta los dos layouts de columnas observados en archivos reales,
316
- detectados por el numero de columnas:
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
- # Anclar la matriz por el TEXTO del encabezado, no por su numeral. Esto
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
- # Saltar headers y separadores
462
- if '---' in line or 'ID' in line.split('|')[1]:
266
+ tc_id = str(c.get('id', '')).strip()
267
+ if not tc_id:
463
268
  continue
464
269
 
465
- cols = [c.strip() for c in line.split('|')[1:-1]]
466
- if len(cols) < 8:
467
- continue
468
- # Verificar que la primera columna es un ID de caso valido
469
- if not case_id_re.match(cols[0]):
470
- continue
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
- tc = parse_matrix_row(cols)
473
- test_cases.append(tc)
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
- # Parser: Apendice Traceability
345
+ # Builder: traceability[] -> mapping requirement -> [tc_ids]
479
346
  # ============================================================
480
347
 
481
- def _parse_traceability_classic(section: str, feature_code: str) -> dict:
482
- """Formato clásico BMAD: '- E001-S001 (name) → TC-P0-CRUD-001, ...'"""
348
+ def build_traceability(design: dict) -> dict:
349
+ """Construye mapping requisito/criterio -> [tc_ids] desde `traceability[]`."""
483
350
  mapping = {}
484
- line_pattern = re.compile(
485
- r'-\s*(E\d+-S\d+)\s*\(.*?\)\s*(?:→|->)\s*(.+?)$', re.MULTILINE
486
- )
487
- for m in line_pattern.finditer(section):
488
- story_id = f'{feature_code}-{m.group(1)}'
489
- tc_ids = re.findall(r'TC-[A-Z0-9]+-[\w]+-\d+', m.group(2))
490
- tc_ids += [t for t in re.findall(r'TC-[A-Z0-9]+-\d+', m.group(2))
491
- if t not in tc_ids]
492
- if tc_ids:
493
- mapping.setdefault(story_id, [])
494
- mapping[story_id] = list(set(mapping[story_id] + tc_ids))
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 _parse_traceability_tree(section: str) -> dict:
499
- """Formato árbol quality-process:
500
- F2 Client Management
501
- ├── Story 2.1 (List & Search) TC-F2-01, TC-F2-02
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
- mapping = {}
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
- return mapping
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
- def parse_traceability(content: str, feature_code: str) -> dict:
541
- """Extrae mapping Story → TCs desde el Apéndice de Trazabilidad.
542
-
543
- Soporta dos formatos:
544
- - Clásico BMAD: '- E001-S001 (name) → TC-P0-CRUD-001'
545
- - Quality-process: árbol con ├── y Story X.Y
546
- """
547
- # Buscar sección del apéndice (varios posibles encabezados)
548
- trace_match = re.search(
549
- r'### Funcionalidad.*?(?:Casos de Prueba|Historias|Features).*?\n(.*?)(?=\n### Trazabilidad|\n### Requerimientos|\Z)',
550
- content, re.DOTALL
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
- # Intentar formato clásico primero
563
- mapping = _parse_traceability_classic(section, feature_code)
392
+ # ============================================================
393
+ # (legacy) Parser: Seccion II — FACs Gherkin
394
+ # ============================================================
564
395
 
565
- # Si no encontró nada, intentar formato árbol quality-process
566
- if not mapping:
567
- mapping = _parse_traceability_tree(section)
396
+ # ============================================================
397
+ # (legacy) Parser: Seccion IV — Matriz 360° (Test Cases)
398
+ # ============================================================
568
399
 
569
- return mapping
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 enrich_stories_from_traceability(content: str, stories: list):
609
- """Extrae nombres de Feature/Epic del texto de traceability y los asigna a stories."""
610
- # Buscar lineas como: **F1 — Gestion del Ciclo de Vida de Segmentos**
611
- feature_blocks = re.findall(
612
- r'\*\*(F\d+)\s*(?:—|--)\s*(.+?)\*\*', content
613
- )
614
- feature_names = {fid: fname.strip() for fid, fname in feature_blocks}
615
-
616
- # Buscar en traceability lineas como:
617
- # - E001-S001 (Entities & EF Core) -> ...
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
- # Buscar nombre de epic
624
- suffix = re.search(r'E\d+-S\d+', s.story_id)
625
- if suffix:
626
- key = suffix.group(0)
627
- if key in epic_names and not s.epic_name:
628
- s.epic_name = epic_names[key]
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 V6.0 -> AgileTest -- Resumen del parseo')
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 V6.0 -> AgileTest: Parser + Cargador',
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 mi-archivo.md --dry-run
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.md generado por quality-process')
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
- # Leer archivo completo
1588
- print(f'Parseando: {os.path.basename(input_path)}')
1589
- with open(input_path, 'r', encoding='utf-8') as f:
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
- # Parsear metadata
1593
- meta = parse_metadata(content)
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
- # Parsear todas las secciones
1598
- stories = parse_stories(content, feature_code)
1599
- facs = parse_facs(content)
1600
- test_cases = parse_test_cases(content)
1601
- trace_map = parse_traceability(content, feature_code)
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
- enrich_stories_from_traceability(content, stories)
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 YAML (fuente única de verdad) al formato Markdown
4
- * que espera el Playwright Test Generator Agent.
5
- *
6
- * Uso:
7
- * npx ts-node tools/yaml-to-playwright-spec.ts <yaml-file> [--parte P3] [--output specs/plan.md]
8
- *
9
- * Si no se indica --parte, convierte todas las partes.
10
- */
11
-
12
- import * as fs from 'fs';
13
- import * as path from 'path';
14
- import * as yaml from 'js-yaml';
15
-
16
- interface Paso {
17
- paso: number;
18
- parte: string;
19
- nombre: string;
20
- accion: string;
21
- resultado: string;
22
- datos?: string;
23
- automatizado?: boolean;
24
- }
25
-
26
- interface TestDesignYaml {
27
- metadata: {
28
- modulo: string;
29
- version: string;
30
- autor: string;
31
- fecha: string;
32
- stack: string;
33
- };
34
- requisito: {
35
- id: string;
36
- nombre: string;
37
- descripcion: string;
38
- };
39
- plan_pruebas: {
40
- id: string;
41
- nombre: string;
42
- objetivo: string;
43
- ambientes: Array<{ nombre: string; url: string; usuario: string }>;
44
- precondiciones: string[];
45
- framework: string;
46
- };
47
- caso_prueba: {
48
- id: string;
49
- nombre: string;
50
- prioridad: string;
51
- tipo: string;
52
- pasos: Paso[];
53
- };
54
- }
55
-
56
- function parseArgs(args: string[]): { yamlFile: string; parte: string | null; output: string | null } {
57
- const yamlFile = args[0];
58
- if (!yamlFile) {
59
- console.error('Uso: npx ts-node tools/yaml-to-playwright-spec.ts <yaml-file> [--parte P3] [--output specs/plan.md]');
60
- process.exit(1);
61
- }
62
-
63
- let parte: string | null = null;
64
- let output: string | null = null;
65
-
66
- for (let i = 1; i < args.length; i++) {
67
- if (args[i] === '--parte' && args[i + 1]) {
68
- parte = args[++i].toUpperCase();
69
- } else if (args[i] === '--output' && args[i + 1]) {
70
- output = args[++i];
71
- }
72
- }
73
-
74
- return { yamlFile, parte, output };
75
- }
76
-
77
- function groupByParte(pasos: Paso[]): Map<string, Paso[]> {
78
- const groups = new Map<string, Paso[]>();
79
- for (const paso of pasos) {
80
- const key = paso.parte;
81
- if (!groups.has(key)) groups.set(key, []);
82
- groups.get(key)!.push(paso);
83
- }
84
- return groups;
85
- }
86
-
87
- function parteLabel(parte: string, pasos: Paso[]): string {
88
- const labels: Record<string, string> = {
89
- P3: 'Datos Personales',
90
- P4: 'Contacto e Información Personal',
91
- P5: 'Formación',
92
- P6: 'Experiencia Laboral',
93
- P7: 'Activos y Pasivos',
94
- P8: 'Datos Adicionales',
95
- };
96
- return labels[parte] || `Parte ${parte}`;
97
- }
98
-
99
- function generateMarkdown(data: TestDesignYaml, filterParte: string | null): string {
100
- const lines: string[] = [];
101
- const allPasos = data.caso_prueba.pasos;
102
- const groups = groupByParte(allPasos);
103
-
104
- // Header
105
- lines.push(`# ${data.plan_pruebas.nombre}`);
106
- lines.push('');
107
- lines.push(`> ${data.plan_pruebas.objetivo.trim()}`);
108
- lines.push('');
109
- lines.push(`**Módulo:** ${data.metadata.modulo} `);
110
- lines.push(`**Stack:** ${data.metadata.stack} `);
111
- lines.push(`**Requisito:** ${data.requisito.id} — ${data.requisito.nombre}`);
112
- lines.push('');
113
-
114
- // Precondiciones
115
- lines.push('## Precondiciones');
116
- for (const pre of data.plan_pruebas.precondiciones) {
117
- lines.push(`- ${pre}`);
118
- }
119
- lines.push('');
120
-
121
- // Ambiente
122
- const env = data.plan_pruebas.ambientes[0];
123
- lines.push('## Ambiente');
124
- lines.push(`- **URL:** ${env.url}`);
125
- lines.push(`- **Usuario:** ${env.usuario}`);
126
- lines.push('');
127
-
128
- // Seed
129
- lines.push(`**Seed:** \`tests/seed.spec.ts\``);
130
- lines.push('');
131
-
132
- // Partes
133
- let sectionNum = 0;
134
- for (const [parte, pasos] of groups) {
135
- if (filterParte && parte !== filterParte) continue;
136
-
137
- sectionNum++;
138
- const label = parteLabel(parte, pasos);
139
- lines.push(`## ${sectionNum}. ${label} (${parte})`);
140
- lines.push('');
141
-
142
- let stepInSection = 0;
143
- for (const paso of pasos) {
144
- stepInSection++;
145
- lines.push(`### ${sectionNum}.${stepInSection} ${paso.nombre}`);
146
- lines.push('');
147
- lines.push('**Steps:**');
148
-
149
- // Descomponer la acción en sub-pasos si contiene "+"
150
- const subActions = paso.accion.split(/\s*\+\s*/);
151
- let stepCounter = 1;
152
- for (const sub of subActions) {
153
- lines.push(`${stepCounter}. ${sub.trim()}`);
154
- stepCounter++;
155
- }
156
-
157
- if (paso.datos) {
158
- lines.push(`${stepCounter}. **Datos:** ${paso.datos}`);
159
- }
160
-
161
- lines.push('');
162
- lines.push(`**Expected:** ${paso.resultado}`);
163
- lines.push('');
164
- }
165
- }
166
-
167
- return lines.join('\n');
168
- }
169
-
170
- // --- Main ---
171
- const args = process.argv.slice(2);
172
- const { yamlFile, parte, output } = parseArgs(args);
173
-
174
- const yamlContent = fs.readFileSync(yamlFile, 'utf-8');
175
- const data = yaml.load(yamlContent) as TestDesignYaml;
176
-
177
- const markdown = generateMarkdown(data, parte);
178
-
179
- // Determinar archivo de salida
180
- const defaultName = parte
181
- ? `portal-empleo-${parte.toLowerCase()}.plan.md`
182
- : 'portal-empleo-completo.plan.md';
183
- const outFile = output || path.join('specs', defaultName);
184
-
185
- // Crear directorio si no existe
186
- const outDir = path.dirname(outFile);
187
- if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
188
-
189
- fs.writeFileSync(outFile, markdown, 'utf-8');
190
- console.log(`✅ Plan generado: ${outFile}`);
191
- console.log(` Partes: ${parte || 'todas'}`);
192
-
193
- const allPasos = data.caso_prueba.pasos;
194
- const filtered = parte ? allPasos.filter(p => p.parte === parte) : allPasos;
195
- console.log(` Pasos: ${filtered.length}`);
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}`);