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 +27 -0
- package/package.json +43 -42
- package/recorder.bat +46 -0
- package/siesa-agents/bmm/workflows/3-solutioning/quality-process/workflow.md +20 -0
- package/siesa-agents/scripts/bmad_to_agiletest.py +55 -38
- package/siesa-agents/scripts/preflight_api_check.py +115 -0
- package/siesa-agents/scripts/requirements.txt +3 -2
- package/siesa-agents/scripts/spec_scaffold.py +205 -0
- package/siesa-agents/scripts/__pycache__/bmad_to_agiletest.cpython-36.pyc +0 -0
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.
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
"
|
|
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
|
-
|
|
1335
|
-
|
|
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
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
'
|
|
1343
|
-
'
|
|
1344
|
-
'
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
'
|
|
1349
|
-
|
|
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
|
-
|
|
1360
|
-
|
|
1361
|
-
'
|
|
1362
|
-
|
|
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
|
-
|
|
1373
|
-
if
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
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
|
|
6
|
-
# PyYAML no viene en la stdlib
|
|
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()
|
|
Binary file
|