siesa-agents 2.1.72-qa.23 → 2.1.72-qa.25

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/bin/install.js CHANGED
@@ -30,6 +30,12 @@ class SiesaBmadInstaller {
30
30
  '_bmad-output/shared-artifacts'
31
31
  ];
32
32
 
33
+ // Archivos sueltos que se copian a la raíz del workspace (no van en folderMappings)
34
+ // recorder.bat: launcher del Grabador de Localizadores v4, resuelve el kit vía %~dp0
35
+ this.rootFiles = [
36
+ 'recorder.bat'
37
+ ];
38
+
33
39
  this.targetDir = process.cwd();
34
40
  // Intentar múltiples ubicaciones posibles para el paquete
35
41
  this.packageDir = this.findPackageDir();
@@ -537,6 +543,27 @@ class SiesaBmadInstaller {
537
543
  console.warn(`⚠️ Carpeta ${mapping.source} no encontrada en el paquete`);
538
544
  }
539
545
  }
546
+
547
+ // Copiar archivos sueltos a la raíz del workspace
548
+ await this.copyRootFiles();
549
+ }
550
+
551
+ async copyRootFiles() {
552
+ for (const fileName of this.rootFiles) {
553
+ const sourcePath = path.join(this.packageDir, fileName);
554
+ const targetPath = path.join(this.targetDir, fileName);
555
+
556
+ if (fs.existsSync(sourcePath)) {
557
+ try {
558
+ await fs.copy(sourcePath, targetPath, { overwrite: true });
559
+ console.log(` ✓ ${fileName}`);
560
+ } catch (error) {
561
+ console.warn(` ⚠️ Error copiando ${fileName}: ${error.message}`);
562
+ }
563
+ } else {
564
+ console.warn(` ⚠️ Archivo ${fileName} no encontrado en el paquete`);
565
+ }
566
+ }
540
567
  }
541
568
 
542
569
  async update() {
package/package.json CHANGED
@@ -1,42 +1,43 @@
1
- {
2
- "name": "siesa-agents",
3
- "version": "2.1.72-qa.23",
4
- "description": "Paquete para instalar y configurar agentes SIESA en tu proyecto",
5
- "main": "index.js",
6
- "bin": {
7
- "siesa-agents": "./bin/install.js"
8
- },
9
- "scripts": {
10
- "postinstall": "node ./bin/install.js",
11
- "build": "echo 'Build complete'"
12
- },
13
- "keywords": [
14
- "siesa",
15
- "bmad",
16
- "agents",
17
- "automation",
18
- "cli"
19
- ],
20
- "author": "Sistemas de Información Empresarial",
21
- "license": "MIT",
22
- "files": [
23
- "bmad/**/*",
24
- "siesa-agents/**/*",
25
- "vscode/**/*",
26
- "github/**/*",
27
- "claude/**/*",
28
- "gemini/**/*",
29
- "kiro/**/*",
30
- "bin/**/*",
31
- "resources/**/*",
32
- "README.md",
33
- "mcp.json"
34
- ],
35
- "engines": {
36
- "node": ">=14.0.0"
37
- },
38
- "dependencies": {
39
- "fs-extra": "^11.1.1",
40
- "path": "^0.12.7"
41
- }
42
- }
1
+ {
2
+ "name": "siesa-agents",
3
+ "version": "2.1.72-qa.25",
4
+ "description": "Paquete para instalar y configurar agentes SIESA en tu proyecto",
5
+ "main": "index.js",
6
+ "bin": {
7
+ "siesa-agents": "./bin/install.js"
8
+ },
9
+ "scripts": {
10
+ "postinstall": "node ./bin/install.js",
11
+ "build": "echo 'Build complete'"
12
+ },
13
+ "keywords": [
14
+ "siesa",
15
+ "bmad",
16
+ "agents",
17
+ "automation",
18
+ "cli"
19
+ ],
20
+ "author": "Sistemas de Información Empresarial",
21
+ "license": "MIT",
22
+ "files": [
23
+ "bmad/**/*",
24
+ "siesa-agents/**/*",
25
+ "vscode/**/*",
26
+ "github/**/*",
27
+ "claude/**/*",
28
+ "gemini/**/*",
29
+ "kiro/**/*",
30
+ "bin/**/*",
31
+ "resources/**/*",
32
+ "README.md",
33
+ "mcp.json",
34
+ "recorder.bat"
35
+ ],
36
+ "engines": {
37
+ "node": ">=14.0.0"
38
+ },
39
+ "dependencies": {
40
+ "fs-extra": "^11.1.1",
41
+ "path": "^0.12.7"
42
+ }
43
+ }
package/recorder.bat ADDED
@@ -0,0 +1,46 @@
1
+ @echo off
2
+ REM ============================================================
3
+ REM Grabador de Localizadores v4 — Launcher
4
+ REM
5
+ REM Uso:
6
+ REM recorder Modo ERP SIESA (login automatico)
7
+ REM recorder blank Lienzo en blanco (sin login)
8
+ REM recorder fin Financiero (qa.finance.siesa.dev)
9
+ REM recorder nom Nomina (qas.nomina.siesa.dev)
10
+ REM ============================================================
11
+
12
+ set "KIT_DIR=%~dp0_siesa-agents\resources\playwright-kit"
13
+
14
+ if not exist "%KIT_DIR%\tools\locator-recorder.spec.ts" (
15
+ echo [Grabador v4] ERROR: No se encontro playwright-kit en %KIT_DIR%
16
+ exit /b 1
17
+ )
18
+
19
+ pushd "%KIT_DIR%"
20
+
21
+ if /i "%~1"=="blank" (
22
+ echo [Grabador v4] Lienzo en blanco
23
+ set "SKIP_LOGIN=1"
24
+ set "START_URL="
25
+ npm run recorder
26
+ set "SKIP_LOGIN="
27
+ ) else if /i "%~1"=="fin" (
28
+ echo [Grabador v4] Financiero — qa.finance.siesa.dev
29
+ set "SKIP_LOGIN=1"
30
+ set "START_URL=https://qa.finance.siesa.dev"
31
+ npm run recorder
32
+ set "SKIP_LOGIN="
33
+ set "START_URL="
34
+ ) else if /i "%~1"=="nom" (
35
+ echo [Grabador v4] Nomina — qas.nomina.siesa.dev
36
+ set "SKIP_LOGIN=1"
37
+ set "START_URL=https://qas.nomina.siesa.dev/mfe/payroll-frontend/"
38
+ npm run recorder
39
+ set "SKIP_LOGIN="
40
+ set "START_URL="
41
+ ) else (
42
+ echo [Grabador v4] ERP SIESA (login automatico)
43
+ npm run recorder
44
+ )
45
+
46
+ popd
@@ -1860,6 +1860,16 @@ los promueve con aprobación humana, ejecuta la suite y deja los reportes listos
1860
1860
  DB_ENGINE=<bd> # texto a buscar en el combo de login (ej: sqlserver)
1861
1861
  ```
1862
1862
  - Confirmar: `✅ .env del Kit configurado (BASE_URL + credenciales).`
1863
+ 5. **Pre-flight de salud de APIs (recomendado):** antes de generar/ejecutar specs, verificar que el
1864
+ backend del servicio responde — evita gastar una corrida E2E completa contra un API caído (p.ej.
1865
+ el 503 de base-config visto en Country Master). Ejecutar:
1866
+ ```bash
1867
+ python {project-root}/_siesa-agents/scripts/preflight_api_check.py \
1868
+ --base-url {BASE_URL} --endpoints "/" --allow-auth
1869
+ ```
1870
+ Ajustar `--endpoints` (lista separada por comas) a los health/ping reales del servicio bajo prueba.
1871
+ Si retorna exit ≠ 0 (algún endpoint caído), **advertir al usuario** y preguntar si continuar o
1872
+ abortar antes de seguir con la generación.
1863
1873
 
1864
1874
  Notificar:
1865
1875
  ```
@@ -1931,6 +1941,16 @@ y continuar con el gap analysis de `prompt_playwright_impl.md` (flujo previo).
1931
1941
  **C — Out-of-scope**. Escribir el código del Bloque B en `{run_folder}/proposed/*.spec.ts`
1932
1942
  (staging — NUNCA directo a `tests/`).
1933
1943
 
1944
+ **Optimización (esqueleto pre-generado):** en modo YAML, antes de invocar al Generator se puede
1945
+ pre-generar el esqueleto `.spec.ts` (un `describe` + un `test()` por caso, con escenario/pasos/
1946
+ datos como comentarios y un `test.fixme()` placeholder) para que el agente solo complete la lógica
1947
+ de cada test en vez de escribir la estructura desde cero (~40K tokens menos por feature):
1948
+ ```bash
1949
+ python {project-root}/_siesa-agents/scripts/spec_scaffold.py \
1950
+ --input "{selected_yml_path}" --output "{run_folder}/proposed/"
1951
+ ```
1952
+ El Generator luego reemplaza cada `test.fixme(...)` por la implementación real sobre ese esqueleto.
1953
+
1934
1954
  **🔵 Review Gate #1:** presentar el plan/clasificación al usuario antes de generar todo el código.
1935
1955
 
1936
1956
  ---
@@ -408,6 +408,14 @@ def augment_trace_with_stories(trace_map: dict, stories: list, test_cases: list)
408
408
 
409
409
  norm_index = {norm(sid): sid for sid in story_ids}
410
410
 
411
+ # H-10: índice epic_prefix -> [story_ids] para expandir refs a nivel de épica.
412
+ # Ej: BASE-CNTRY-E001-S001 y BASE-CNTRY-E001-S002 -> {'BASE-CNTRY-E001': [ambas]}
413
+ epic_to_stories = {}
414
+ for sid in story_ids:
415
+ m = re.match(r'(.+-E\d+)-S\d+', sid)
416
+ if m:
417
+ epic_to_stories.setdefault(m.group(1), []).append(sid)
418
+
411
419
  for tc in test_cases:
412
420
  if not tc.epics:
413
421
  continue
@@ -419,6 +427,16 @@ def augment_trace_with_stories(trace_map: dict, stories: list, test_cases: list)
419
427
  trace_map.setdefault(sid, [])
420
428
  if tc.tc_id not in trace_map[sid]:
421
429
  trace_map[sid].append(tc.tc_id)
430
+
431
+ # H-10: refs a nivel de épica (ej. BASE-CNTRY-E001, sin sufijo -S###)
432
+ # se expanden a TODAS las historias de esa épica. Sin esto, un test_matrix
433
+ # con `epics: [BASE-CNTRY-E001]` no generaba ningún link TC -> Requisito.
434
+ for epic_key, sids in epic_to_stories.items():
435
+ if epic_key in tc.epics:
436
+ for sid in sids:
437
+ trace_map.setdefault(sid, [])
438
+ if tc.tc_id not in trace_map[sid]:
439
+ trace_map[sid].append(tc.tc_id)
422
440
  return trace_map
423
441
 
424
442
  # ============================================================
@@ -1328,52 +1346,51 @@ def create_in_agiletest(stories, test_cases, trace_map, meta, config, state_dir=
1328
1346
  # ============================================================
1329
1347
 
1330
1348
  def _extract_agiletest_section(content: str, source_label: str, include_secrets: bool):
1331
- """Extrae y aplica valores de la sección agiletest: de un YAML."""
1332
- import re as _re
1349
+ """Extrae y aplica valores de la sección agiletest: de un YAML.
1333
1350
 
1334
- at_match = _re.search(r'^agiletest:\s*\n((?:[ \t]+.+\n?)*)', content, _re.MULTILINE)
1335
- if not at_match:
1351
+ H-09: se parsea con PyYAML en vez de regex línea-por-línea. El regex anterior
1352
+ `(.+?)` quedaba anclado a fin de línea y capturaba comentarios inline, p.ej.
1353
+ `issue_type_task: "10013" # Tarea` se cargaba como `10013" # Tarea`,
1354
+ produciendo HTTP 400 en Jira. PyYAML ignora los comentarios nativamente.
1355
+ """
1356
+ try:
1357
+ import yaml
1358
+ raw = yaml.safe_load(content)
1359
+ except Exception as e:
1360
+ safe_print(f' [WARN] No se pudo parsear {source_label} como YAML: {e}')
1361
+ return
1362
+ if not isinstance(raw, dict):
1363
+ return
1364
+ at = raw.get('agiletest')
1365
+ if not isinstance(at, dict):
1336
1366
  return
1337
1367
 
1338
- section = at_match.group(1)
1339
-
1340
- # Campos no-secretos
1341
- non_secret_mapping = {
1342
- 'jira_base_url': 'jira_base_url',
1343
- 'jira_project_key': 'jira_project_key',
1344
- 'jira_user_email': 'jira_user_email',
1345
- 'agiletest_base_url': 'agiletest_base_url',
1346
- 'agiletest_project_id': 'agiletest_project_id',
1347
- 'issue_type_task': 'issue_type_task',
1348
- 'issue_type_test_case': 'issue_type_test_case',
1349
- 'issue_type_test_plan': 'issue_type_test_plan',
1350
- 'issue_type_test_execution': 'issue_type_test_execution',
1351
- }
1352
- for yaml_key, config_key in non_secret_mapping.items():
1353
- m = _re.search(rf'^\s+{yaml_key}:\s*["\']?(.+?)["\']?\s*$', section, _re.MULTILINE)
1354
- if m:
1355
- CONFIG[config_key] = m.group(1).strip()
1368
+ # Campos no-secretos. Solo se sobreescribe si el valor no está vacío,
1369
+ # para no pisar los defaults / env vars con un placeholder vacío del config.
1370
+ non_secret_keys = [
1371
+ 'jira_base_url', 'jira_project_key', 'jira_user_email',
1372
+ 'agiletest_base_url', 'agiletest_project_id',
1373
+ 'issue_type_task', 'issue_type_test_case',
1374
+ 'issue_type_test_plan', 'issue_type_test_execution',
1375
+ ]
1376
+ for key in non_secret_keys:
1377
+ val = at.get(key)
1378
+ if val is not None and str(val).strip() != '':
1379
+ CONFIG[key] = str(val).strip()
1356
1380
 
1357
1381
  # Campos secretos (solo se leen del secrets file, no del config.yaml público)
1358
1382
  if include_secrets:
1359
- secret_mapping = {
1360
- 'jira_api_token': 'jira_api_token',
1361
- 'agiletest_client_id': 'agiletest_client_id',
1362
- 'agiletest_client_secret':'agiletest_client_secret',
1363
- }
1364
- for yaml_key, config_key in secret_mapping.items():
1365
- m = _re.search(rf'^\s+{yaml_key}:\s*["\']?(.+?)["\']?\s*$', section, _re.MULTILINE)
1366
- if m:
1367
- value = m.group(1).strip().strip('"').strip("'")
1368
- if value: # ignorar placeholders vacíos
1369
- CONFIG[config_key] = value
1383
+ for key in ['jira_api_token', 'agiletest_client_id', 'agiletest_client_secret']:
1384
+ val = at.get(key)
1385
+ if val is not None and str(val).strip() != '':
1386
+ CONFIG[key] = str(val).strip()
1370
1387
 
1371
1388
  # priority_map: P0/P1/P2/P3
1372
- prio_match = _re.search(r'priority_map:\s*\n((?:\s+P\d:.+\n?)*)', section)
1373
- if prio_match:
1374
- import re as _re2
1375
- for pm in _re2.finditer(r'(P\d):\s*["\']?(.+?)["\']?\s*$', prio_match.group(1), _re2.MULTILINE):
1376
- CONFIG['priority_map'][pm.group(1)] = pm.group(2).strip()
1389
+ pmap = at.get('priority_map')
1390
+ if isinstance(pmap, dict):
1391
+ for k, v in pmap.items():
1392
+ if v is not None and str(v).strip() != '':
1393
+ CONFIG['priority_map'][str(k)] = str(v).strip()
1377
1394
 
1378
1395
 
1379
1396
  def load_bmm_config(config_path: str):
@@ -0,0 +1,115 @@
1
+ """
2
+ preflight_api_check.py
3
+ ----------------------
4
+ Chequeo de salud de las APIs/endpoints de un servicio ANTES de lanzar los specs de
5
+ Playwright (Fase 6 del quality-process). Evita gastar una corrida E2E completa contra
6
+ un backend caído — como el 503 de base-config detectado en Country Master.
7
+
8
+ Hace un GET a cada endpoint y reporta el status HTTP. Considera "sano":
9
+ - 2xx / 3xx siempre,
10
+ - 401 / 403 solo si se pasa --allow-auth (el servicio responde aunque exija auth).
11
+ Marca como caído: 5xx, 4xx (salvo auth permitido), timeouts y errores de conexión.
12
+
13
+ Exit code:
14
+ 0 -> todos los endpoints sanos.
15
+ 1 -> al menos un endpoint caído (corta el pipeline antes de correr los specs).
16
+
17
+ Uso:
18
+ python _siesa-agents/scripts/preflight_api_check.py --base-url https://qa.finance.siesa.dev
19
+ python _siesa-agents/scripts/preflight_api_check.py --base-url https://qa.finance.siesa.dev \
20
+ --endpoints /health,/api/base-config/v1/ping --timeout 10 --allow-auth
21
+
22
+ Solo usa la stdlib (urllib) — no requiere dependencias extra.
23
+ """
24
+
25
+ import argparse
26
+ import ssl
27
+ import sys
28
+ import urllib.error
29
+ import urllib.request
30
+
31
+
32
+ def join_url(base: str, endpoint: str) -> str:
33
+ base = base.rstrip('/')
34
+ endpoint = (endpoint or '').strip()
35
+ if not endpoint:
36
+ return base or '/'
37
+ if endpoint.startswith(('http://', 'https://')):
38
+ return endpoint
39
+ if not endpoint.startswith('/'):
40
+ endpoint = '/' + endpoint
41
+ return base + endpoint
42
+
43
+
44
+ def check(url: str, timeout: float, insecure: bool):
45
+ """Devuelve (status:int|None, error:str). status None => no respondió."""
46
+ ctx = ssl.create_default_context()
47
+ if insecure:
48
+ ctx.check_hostname = False
49
+ ctx.verify_mode = ssl.CERT_NONE
50
+ req = urllib.request.Request(
51
+ url, method='GET', headers={'User-Agent': 'siesa-preflight/1.0'}
52
+ )
53
+ try:
54
+ with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
55
+ return resp.status, ''
56
+ except urllib.error.HTTPError as e:
57
+ # El servidor respondió con un código de error (4xx/5xx).
58
+ return e.code, ''
59
+ except urllib.error.URLError as e:
60
+ return None, str(e.reason)
61
+ except Exception as e: # noqa: BLE001 — cualquier fallo de red cuenta como caído
62
+ return None, str(e)
63
+
64
+
65
+ def is_healthy(status, allow_auth: bool) -> bool:
66
+ if status is None:
67
+ return False
68
+ if 200 <= status < 400:
69
+ return True
70
+ if allow_auth and status in (401, 403):
71
+ return True
72
+ return False
73
+
74
+
75
+ def main() -> None:
76
+ parser = argparse.ArgumentParser(
77
+ description='Chequeo de salud de APIs antes de correr los specs E2E (Fase 6).'
78
+ )
79
+ parser.add_argument('--base-url', required=True, help='URL base del servicio (ej: https://qa.finance.siesa.dev)')
80
+ parser.add_argument('--endpoints', default='/',
81
+ help='Lista separada por comas de paths a probar (default: /). '
82
+ 'Ej: /health,/api/base-config/v1/ping')
83
+ parser.add_argument('--timeout', type=float, default=10.0, help='Timeout por request en segundos (default: 10)')
84
+ parser.add_argument('--allow-auth', action='store_true',
85
+ help='Trata 401/403 como sano (el servicio responde aunque exija auth)')
86
+ parser.add_argument('--insecure', action='store_true', help='No verificar el certificado TLS')
87
+ args = parser.parse_args()
88
+
89
+ endpoints = [e for e in (args.endpoints or '/').split(',')]
90
+ if not endpoints:
91
+ endpoints = ['/']
92
+
93
+ print(f'\n Preflight de APIs — base: {args.base_url}\n')
94
+ down = []
95
+ for ep in endpoints:
96
+ url = join_url(args.base_url, ep)
97
+ status, err = check(url, args.timeout, args.insecure)
98
+ ok = is_healthy(status, args.allow_auth)
99
+ mark = 'OK ' if ok else 'DOWN'
100
+ status_txt = str(status) if status is not None else f'sin respuesta ({err})'
101
+ print(f' [{mark}] {status_txt:>22} {url}')
102
+ if not ok:
103
+ down.append(url)
104
+
105
+ print()
106
+ if down:
107
+ print(f' RESULTADO: {len(down)} de {len(endpoints)} endpoint(s) caído(s). '
108
+ f'NO se recomienda correr los specs E2E todavía.')
109
+ sys.exit(1)
110
+ print(f' RESULTADO: los {len(endpoints)} endpoint(s) responden. Listo para correr los specs E2E.')
111
+ sys.exit(0)
112
+
113
+
114
+ if __name__ == '__main__':
115
+ main()
@@ -2,6 +2,7 @@
2
2
  # Instalar una sola vez en el entorno donde se corran los scripts:
3
3
  # pip install -r requirements.txt
4
4
  #
5
- # bmad_to_agiletest.py usa yaml.safe_load() para leer test-design.yml (BMAD V7.8).
6
- # PyYAML no viene en la stdlib de Python, por eso se declara aqui.
5
+ # bmad_to_agiletest.py y spec_scaffold.py usan yaml.safe_load() para leer el
6
+ # test-design.yml (BMAD V7.8). PyYAML no viene en la stdlib, por eso se declara aqui.
7
+ # preflight_api_check.py solo usa la stdlib (urllib) — no requiere nada extra.
7
8
  PyYAML>=6.0
@@ -0,0 +1,205 @@
1
+ """
2
+ spec_scaffold.py
3
+ ----------------
4
+ Genera el ESQUELETO de archivos `.spec.ts` de Playwright a partir del `test-design.yml`
5
+ (BMAD V7.8). Crea un archivo por feature con un `test.describe` y un `test()` por cada
6
+ caso del `test_matrix`, embebiendo todo el contexto del diseño (escenario, precondiciones,
7
+ pasos atómicos, resultado esperado y datos) como comentarios, más un `test.fixme()`
8
+ placeholder.
9
+
10
+ El agente Generator (Fase 6 del quality-process) completa la lógica de cada test sobre
11
+ este esqueleto — no tiene que generar la estructura describe/it desde cero, lo que ahorra
12
+ ~40K tokens por feature.
13
+
14
+ Uso:
15
+ python _siesa-agents/scripts/spec_scaffold.py --input test-design.yml --output tests/
16
+ python _siesa-agents/scripts/spec_scaffold.py --input test-design.yml --output tests/ --force
17
+
18
+ Flags:
19
+ --input Ruta al test-design.yml (obligatorio).
20
+ --output Carpeta destino de los .spec.ts (se crea si no existe). Default: tests/
21
+ --force Sobreescribe archivos .spec.ts ya existentes (por defecto NO sobreescribe).
22
+
23
+ Requiere PyYAML (pip install -r requirements.txt).
24
+ """
25
+
26
+ import argparse
27
+ import os
28
+ import re
29
+ import sys
30
+
31
+
32
+ def load_yaml(path: str) -> dict:
33
+ """Carga el test-design.yml como dict. Requiere PyYAML (igual que el resto del kit)."""
34
+ try:
35
+ import yaml
36
+ except ImportError:
37
+ print(' ERROR: falta PyYAML. Instala con: pip install -r requirements.txt (o: pip install pyyaml)')
38
+ sys.exit(1)
39
+ if not os.path.isfile(path):
40
+ print(f' ERROR: no existe el archivo de entrada: {path}')
41
+ sys.exit(1)
42
+ with open(path, 'r', encoding='utf-8') as f:
43
+ data = yaml.safe_load(f)
44
+ if not isinstance(data, dict):
45
+ print(' ERROR: el test-design.yml no tiene un mapeo (dict) en la raíz — revisa el YAML.')
46
+ sys.exit(1)
47
+ return data
48
+
49
+
50
+ def slugify(text: str) -> str:
51
+ """Convierte un nombre de feature en un nombre de archivo seguro."""
52
+ s = re.sub(r'[^a-zA-Z0-9]+', '-', (text or '').strip().lower()).strip('-')
53
+ return s or 'general'
54
+
55
+
56
+ def one_line(text: str) -> str:
57
+ """Colapsa cualquier valor a una sola línea (apto para comentario //)."""
58
+ return re.sub(r'\s+', ' ', str(text or '').replace('\r', ' ').replace('\n', ' ')).strip()
59
+
60
+
61
+ def js_str(text: str) -> str:
62
+ """Escapa un texto para usarlo dentro de un string JS con comillas simples."""
63
+ return one_line(text).replace('\\', '\\\\').replace("'", "\\'")
64
+
65
+
66
+ def epics_str(value) -> str:
67
+ if isinstance(value, list):
68
+ return ', '.join(str(x) for x in value)
69
+ return str(value or '')
70
+
71
+
72
+ def build_test(case: dict) -> list:
73
+ """Genera las líneas del bloque test() para un caso del test_matrix."""
74
+ cid = one_line(case.get('id', ''))
75
+ scenario = one_line(case.get('scenario', ''))
76
+ level = one_line(case.get('level', ''))
77
+ priority = one_line(case.get('priority', ''))
78
+ risk = one_line(case.get('risk', ''))
79
+ technique = one_line(case.get('technique', ''))
80
+ interface_type = one_line(case.get('interface_type', ''))
81
+ epics = epics_str(case.get('epics', []))
82
+ preconditions = one_line(case.get('preconditions', ''))
83
+ expected = one_line(case.get('expected_result', ''))
84
+
85
+ title = scenario or cid or 'caso sin id'
86
+ out = []
87
+ out.append(f' // ── {cid or "(sin id)"} ──────────────────────────────────────')
88
+ meta = f' // Nivel: {level} | Prioridad: {priority} | Riesgo: {risk}'
89
+ if technique:
90
+ meta += f' | Técnica: {technique}'
91
+ if interface_type:
92
+ meta += f' | Interfaz: {interface_type}'
93
+ out.append(meta)
94
+ if epics:
95
+ out.append(f' // Épicas/Historias: {epics}')
96
+ if preconditions and preconditions not in ('—', '-'):
97
+ out.append(f' // Precondiciones: {preconditions}')
98
+ if expected:
99
+ out.append(f' // Resultado esperado: {expected}')
100
+
101
+ out.append(f" test('{js_str(cid)} — {js_str(title)}', async ({{ page }}) => {{")
102
+
103
+ steps = case.get('steps', []) or []
104
+ if steps:
105
+ out.append(' // Pasos del diseño (reemplazar por la implementación real):')
106
+ for i, s in enumerate(steps, 1):
107
+ if isinstance(s, dict):
108
+ accion = one_line(s.get('accion', ''))
109
+ resultado = one_line(s.get('resultado', ''))
110
+ datos = one_line(s.get('datos', ''))
111
+ else:
112
+ accion, resultado, datos = one_line(s), '', ''
113
+ note = f' // {i}. {accion}'
114
+ if resultado:
115
+ note += f' -> {resultado}'
116
+ if datos:
117
+ note += f' [datos: {datos}]'
118
+ out.append(note)
119
+ else:
120
+ out.append(' // (El diseño no trae pasos atómicos — derivar del escenario.)')
121
+
122
+ out.append(" test.fixme(true, 'Pendiente de implementación por el agente Generator (Fase 6).');")
123
+ out.append(' });')
124
+ return out
125
+
126
+
127
+ def build_spec(feature: str, cases: list) -> str:
128
+ """Genera el contenido completo de un .spec.ts para una feature."""
129
+ lines = [
130
+ '/**',
131
+ f' * {one_line(feature) or "General"} — esqueleto generado por spec_scaffold.py',
132
+ ' *',
133
+ ' * Generado automáticamente desde test-design.yml (BMAD V7.8).',
134
+ ' * El agente Generator (Fase 6) debe reemplazar cada test.fixme() por la',
135
+ ' * implementación real de los pasos. No alterar la estructura describe/test',
136
+ ' * sin actualizar el diseño fuente.',
137
+ ' */',
138
+ "import { test, expect } from '@playwright/test';",
139
+ '',
140
+ '// Credenciales vía env vars (alineadas con .env.example del Kit Playwright).',
141
+ 'const CRED = {',
142
+ " user: process.env.TEST_USER ?? '',",
143
+ " pass: process.env.TEST_PASS ?? '',",
144
+ " bd: process.env.DB_ENGINE ?? '',",
145
+ '};',
146
+ '',
147
+ f"test.describe('{js_str(feature) or 'General'}', () => {{",
148
+ '',
149
+ ]
150
+ for c in cases:
151
+ lines.extend(build_test(c))
152
+ lines.append('')
153
+ lines.append('});')
154
+ lines.append('')
155
+ return '\n'.join(lines)
156
+
157
+
158
+ def main() -> None:
159
+ parser = argparse.ArgumentParser(description='Genera esqueletos .spec.ts desde test-design.yml (BMAD V7.8).')
160
+ parser.add_argument('--input', required=True, help='Ruta al test-design.yml')
161
+ parser.add_argument('--output', default='tests/', help='Carpeta destino de los .spec.ts (default: tests/)')
162
+ parser.add_argument('--force', action='store_true', help='Sobreescribe archivos existentes')
163
+ args = parser.parse_args()
164
+
165
+ design = load_yaml(args.input)
166
+ matrix = design.get('test_matrix', []) or []
167
+ if not isinstance(matrix, list) or not matrix:
168
+ print(' ERROR: el test-design.yml no tiene una sección test_matrix con casos.')
169
+ sys.exit(1)
170
+
171
+ # Agrupar casos por feature, preservando el orden de aparición.
172
+ by_feature = {}
173
+ for c in matrix:
174
+ if not isinstance(c, dict) or not str(c.get('id', '')).strip():
175
+ continue
176
+ feature = one_line(c.get('feature', '')) or 'General'
177
+ by_feature.setdefault(feature, []).append(c)
178
+
179
+ if not by_feature:
180
+ print(' ERROR: no se encontró ningún caso válido (con id) en test_matrix.')
181
+ sys.exit(1)
182
+
183
+ os.makedirs(args.output, exist_ok=True)
184
+
185
+ written, skipped, total_cases = 0, 0, 0
186
+ for feature, cases in by_feature.items():
187
+ filename = f'{slugify(feature)}.spec.ts'
188
+ path = os.path.join(args.output, filename)
189
+ total_cases += len(cases)
190
+ if os.path.exists(path) and not args.force:
191
+ skipped += 1
192
+ print(f' [SKIP] ya existe (usa --force para sobreescribir): {path}')
193
+ continue
194
+ with open(path, 'w', encoding='utf-8') as f:
195
+ f.write(build_spec(feature, cases))
196
+ written += 1
197
+ print(f' [OK] {path} ({len(cases)} casos)')
198
+
199
+ print()
200
+ print(f' Resumen: {written} spec(s) generado(s), {skipped} omitido(s), '
201
+ f'{total_cases} casos en {len(by_feature)} feature(s).')
202
+
203
+
204
+ if __name__ == '__main__':
205
+ main()