Este documento fija TODAS las decisiones técnicas del proyecto para que distintos agentes/sesiones de desarrollo construyan de forma consistente. Léelo por completo antes de escribir código. Si necesitas desviarte de algo aquí, actualiza este archivo primero y explica por qué en la sección "Cambios registrados" al final.
- Nombre npm:
qa-evidence-reporter - Bin CLI:
qa-evidence-reporter(comandos:init,run,report) - Lenguaje: TypeScript estricto (
strict: trueen tsconfig), target ES2022, module NodeNext. - Package manager: npm. Node mínimo soportado: 18 LTS+.
- Monorepo: NO. Un solo paquete npm, pero con
ui/como sub-build (Vite) cuyodistse empaqueta dentro del paquete publicado y el server lo sirve como estáticos.
qa-evidence-reporter/
├── src/
│ ├── core/
│ │ ├── parser/
│ │ ├── session/
│ │ ├── evidence/
│ │ ├── report/
│ │ ├── config/ # carga/valida qa-config.json (vive en core: es lógica de negocio, no de transporte)
│ │ ├── logger/ # wrapper de pino, expone interfaz Logger propia (no acoplar a pino en el resto del core)
│ │ └── types/
│ ├── adapters/
│ │ ├── cli/
│ │ └── server/
│ └── ui/
├── templates/ # templates Handlebars del reporte
├── sample-project/
├── tests/ # tests de integración end-to-end (unit tests viven junto a cada módulo: *.test.ts)
├── tsconfig.json
├── package.json
├── .eslintrc.cjs / eslint.config.js
└── .prettierrc
Regla de dependencia estricta (ESLint debe hacerla cumplir con
eslint-plugin-boundaries o reglas de no-restricted-imports si boundaries es
complejo de configurar — usar lo que sea más simple de mantener):
core/**nunca importa deadapters/**ni deui/**.adapters/cli/**yadapters/server/**importan decore/**, nunca entre sí directamente (server no importa cli ni viceversa).ui/**solo llama aadapters/servervíafetchHTTP. Nunca importacore.
| Área | Elección | Por qué |
|---|---|---|
| CLI framework | commander |
Más simple que yargs para 3 comandos |
| Server | express |
Estándar, simple, suficiente (no se necesita Fastify) |
| Comunicación UI↔server | REST puro (fetch), sin WebSocket | Un solo usuario por sesión local; WebSocket añade complejidad sin beneficio real aquí |
| UI framework | preact + preact/hooks, bundler vite (@preact/preset-vite) |
Bundle más pequeño que React, JSX igual de ergonómico |
| Parser Gherkin | @cucumber/gherkin + @cucumber/messages |
Oficial, robusto, soporta i18n (español vía # language: es, e inglés por defecto) |
| Templates de reporte | handlebars |
Legible, soporta partials/helpers, fácil de tematizar |
| Charts del reporte | SVG generado server-side (funciones TS puras, sin librería) | Garantiza funcionamiento offline sin bundlear Chart.js |
| ZIP export | archiver |
Streaming, maduro |
| Thumbnails de imagen | jimp (puro JS, sin binarios nativos) |
Evita problemas de instalación cross-platform (requisito "cero config extra") |
| Thumbnails de video | NO se extrae frame real. Se usa un ícono genérico de video (SVG estático) | ffmpeg nativo es frágil de bundlear cross-platform; el spec permite explícitamente este fallback |
| Logging | pino envuelto en core/logger con interfaz propia Logger (debug/info/warn/error) |
Nivel configurable desde qa-config.json |
| Tests | vitest |
Rápido, buen soporte ESM/TS nativo |
| Lint/formato | eslint (flat config) + prettier |
— |
| Validación de config | zod |
Esquema tipado + validación runtime en un solo lugar |
Definir (mínimo, ampliar según se necesite, pero no romper estos nombres):
core/types/parser.ts:ParsedFeature,ParsedScenario,ParsedStep,GherkinParser(interfaz:parseFile(filePath): Promise<ParsedFeature>,parseDirectory(dirPath): Promise<ParsedFeature[]>).core/types/session.ts:SessionState(con campoversion: number),FeatureExecution,ScenarioExecution,StepExecution,StepResult = 'pass' | 'fail' | 'skip' | 'pending',SessionEngine(interfaz: crear/cargar/guardar sesión, avanzar/retroceder step, setear resultado, adjuntar evidencia, notas, descripción de defecto).core/types/evidence.ts:EvidenceFile,EvidenceKind = 'image' | 'video' | 'pdf' | 'other',EvidenceStore(interfaz:save,getThumbnail,list). Registro extensible de formatos soportados (mapa extensión→kind, no if/else).core/types/report.ts:ReportData(agregado completo desdeSessionState- metadata de proyecto),
ReportGenerator(interfaz:generate(sessionState, outputDir, templateDir?): Promise<void>),TemplateEngine(interfaz para permitir templates customizables).
- metadata de proyecto),
core/types/config.ts:QaConfig(esquema zod + tipo inferido), valores default documentados.core/types/errors.ts: clases de error custom (FeatureParseError,EvidenceFileTooLargeError,SessionNotFoundError,UnsupportedEvidenceFormatError,ReportGenerationError,ConfigValidationError), todas concode: stringy extendiendo una baseQaError extends Error.
{
"$schema": "./node_modules/qa-evidence-reporter/config.schema.json",
"projectName": "Mi Proyecto QA",
"team": [],
"featuresDir": "features",
"evidenceDir": "evidence",
"reportsDir": "reports",
"server": { "port": 3000, "openBrowser": true },
"evidence": { "maxFileSizeMB": 50, "allowedFormats": ["png", "jpg", "jpeg", "gif", "mp4", "webm", "pdf"] },
"logging": { "level": "info" },
"branding": { "logoPath": null, "primaryColor": null, "accentColor": null, "highlightColor": null, "ctaColor": null },
"reportTemplate": null
}branding (agregado post-fase 6, ver "Cambios registrados" al final): opcional,
para que el reporte HTML y el runner muestren el logo/paleta de una
empresa/cliente en vez del tema neutro por defecto. logoPath es relativo a
la raíz del proyecto (mismo criterio que featuresDir); los 4 colores son
hex (#rgb/#rrggbb), validados por core/config. Todo null (el default)
= sin cambios visuales respecto a como era antes de esta feature.
Debe incluir version: 1 desde el inicio, para permitir migraciones futuras.
Se guarda en .qa-evidence-reporter/session.json dentro del proyecto del QA
(oculto, no en reports/ ni evidence/). Debe permitir resumir tras cerrar el
navegador: guarda feature/scenario/step actual, resultados y evidencias ya
adjuntadas por step, notas, descripciones de defectos.
evidence/{featureSlug}/{scenarioSlug}/{stepIndex}-{stepSlug}/{originalFilename}
Thumbnails junto al archivo original: mismo nombre + .thumb.png (jimp) o
ícono genérico compartido para video (un solo SVG en templates/assets/).
GET /api/features— features + estado actual de la sesión (crea sesión si no existe)POST /api/session/select— elegir qué features correr, inicia ejecuciónGET /api/session— estado completo de la sesión actualPOST /api/session/step/:stepId/evidence— subir archivo(s) (multipart, usarmulter)POST /api/session/step/:stepId/result— marcar pass/fail/skip (+ notas / defecto). Marcar un step comoskipcascada a TODOS los steps del mismo scenario (pisando cualquier resultado previo): omitir es una decisión sobre el caso de prueba completo, no sobre un step aislado — un scenario "parcialmente omitido" no es un estado válido (versetStepResult/findScenarioContainingStepensessionEngine.ts).POST /api/session/navigate— moverse a step anterior/siguiente/arbitrarioPOST /api/report/generate— genera el reporte HTML enreports/GET /api/report/export-zip— devuelve el .zip del último reporte generado- Estáticos: server sirve
evidence/(para previews) y el build deui/.
reports/index.html(dashboard) +reports/features/{slug}.html(detalle), auto-contenido: CSS/JS inline o copiados areports/assets/, imágenes de evidencia copiadas areports/assets/evidence/.... Debe abrir confile://sin servidor.- Tema claro/oscuro vía
prefers-color-scheme+ toggle JS inline (sin dependencias).
- Atajos de teclado: P/F/S/N/B (deshabilitados si el foco está en un
<textarea>/<input>). - Paste de clipboard (
Ctrl+V) sobre el área de evidencia → sube imagen. - Drag & drop de archivos sobre la misma área.
- Tema claro/oscuro con toggle, persistido en
localStorage. - Responsive desde 1280px (no se exige mobile).
- Scaffolding npm + tsconfig + eslint/prettier +
core/types+core/parser+ tests. core/session+core/evidence+ tests.core/report(SVG charts + templates Handlebars) + tests.core/config+core/logger+adapters/cli(init/run/report).adapters/server(API REST) +ui(Preact runner completo).sample-project/+ test de integración end-to-end + export ZIP + pulido final (lint, README).
- Nueva carpeta
src/core/shared/(no estaba en la estructura de directorios original de este documento): contieneslugify.ts, una utilidad pura usada tanto porcore/session(para generar los ids legibles de feature/scenario/step) como porcore/evidence(indirectamente, a través de esos mismos ids usados como nombres de carpeta). Se puso en un lugar neutral en vez de duplicarla o hacer que un módulo dependa del otro. - Esquema de ids de
FeatureExecution/ScenarioExecution/StepExecution:"f{featureIndex}-{slug(name)}","{featureId}_s{scenarioIndex}-{slug(name)}","{scenarioId}_st{stepIndex}"respectivamente. Cada nivel encadena el id de su padre, así queStepExecution.idtermina siendo único en TODA la sesión (no solo dentro de su scenario) sin necesitar mantener un índice global aparte. Por esto, todas las operaciones deSessionEnginesobre un step puntual (setStepResult,addEvidence,removeEvidence,addNotes) reciben unstepId: stringsimple, no una terna compuesta. EvidenceStore.saveusafeatureId/scenarioId/stepId(la misma terna que devuelveSessionEngine.getCurrentStep()) directamente como los tres niveles de carpeta bajoevidence/. Como esos ids ya encadenan a su padre (ver punto anterior), el nombre de carpeta de scenario/step queda con el prefijo del padre incluido (p. ej.evidence/f0-login/f0-login_s0-successful-login/f0-login_s0-successful-login_st0/) en vez de soloevidence/login/successful-login/0-.../. Se aceptó esta redundancia cosmética a cambio de queEvidenceStoreno necesite conocer ni confiar en el formato interno de los ids decore/session(acoplamiento mínimo entre ambos módulos) y de que cada nivel siga siendo, por construcción, único como nombre de carpeta.EvidenceFile.ides un hash corto y determinístico defeatureId:scenarioId:stepId:originalFilename(no depende de timestamp ni de contenido del archivo). Esto permite queEvidenceStore.list/getThumbnailreconstruyan la metadata completa escaneando el filesystem (recalculando el mismo id a partir de la ruta de cada archivo encontrado), sin mantener un índice separado en disco o en memoria — útil para recuperación sisession.jsonse corrompe. Efecto secundario documentado: subir dos veces el mismooriginalFilenamepara el mismo step pisa el archivo anterior (mismo id, misma ruta).ScenarioExecution/FeatureExecutionno tienen un camporesultpersistido: se calcula con las funciones purasderiveScenarioResult/deriveFeatureResult(exportadas desdecore/types/session.ts) con la prioridadfail > pending > skip > pass, para evitar una fuente de verdad duplicada que se pueda desincronizar desteps.
TemplateEngineganó un tercer método,getStaticAssetsDir(): string | null, además derender/getAvailableTemplateNames(los dos previstos en la sección de contratos de este documento). Es necesario porqueReportGenerator.generate()debe copiar assets estáticos del template (p. ej.templates/default/assets/video-icon.svg) aoutputDir/assets/, y ese es el único paso del pipeline que necesita conocer una ruta de filesystem del lado del template en vez de solo strings renderizados. Una implementación custom deTemplateEngineque no tenga assets propios (todo en memoria) puede devolvernullsin rompergenerate().createReportGeneratorrecibe un tercer parámetrodepsopcional (ReportGeneratorDeps = { clock?, templateEngineFactory? }), además de(config, templateEngine). Mismo patrón queSessionEngineDeps(clock) yEvidenceStoreDeps(imageProcessor) de fase 2: dependencias opcionales con default de producción, para hacer testeable lo que sería no-determinístico (generatedAt) o lo que exigiría instanciarhandlebarsde verdad en un test (templateEngineFactory, usado únicamente cuandogenerate()recibeoptions.templateDir— ver siguiente punto).GenerateReportOptions.templateDirno reconstruye unTemplateEnginellamando ahandlebarsdirectamente:reportGenerator.tsnunca importa el paquetehandlebars(solo la interfazTemplateEnginey, como default detemplateEngineFactory, la funcióncreateHandlebarsTemplateEnginedetemplateEngine.ts— el único archivo del módulo que sí tocahandlebars). Así se respeta la regla de esta misma fase ("no instanciar Handlebars directamente dentro del generador") incluso en la rama dondegenerate()recibe untemplateDirdistinto al delTemplateEngineinyectado en la factory.FeatureReportView.sluges literalmentefeature.id, no un slug recalculado a partir defeature.name.FeatureExecution.id(fase 2,"f{featureIndex}-{slug(name)}") ya es un slug único y estable dentro de la sesión; volver a "sluggificar" el nombre por separado sería una segunda fuente de verdad para el mismo concepto y podría, en el caso de dos features con el mismo nombre, producir un slug igual para dosdetailPathque deberían ser distintos.- Paleta de color única fuente de verdad: los 4 colores de estado
(
pass/fail/skip/pending) se definen UNA vez, en hex, enRESULT_COLORS(src/core/report/charts.ts) — usados tanto por el SVG del donut como por el helperresultLabelde Handlebars (que también reexportaRESULT_LABELSdel mismo archivo). El CSS detemplates/default/partials/styles.hbsSÍ repite los mismos valores hex a mano (CSS no puede importar un módulo TypeScript) — está documentado con un comentario cruzado en ambos archivos para que no diverjan silenciosamente si se cambia uno. - Donut chart implementado con
stroke-dasharray/stroke-dashoffsetsobre<circle>apiladas (técnica estándar de "donut de CSS/SVG"), no con<path>+ arcos: evita la aritmética de arcos (A rx,ry ...) y sus casos límite (porciones de 100%, ángulos > 180°), y el agujero central es gratis (es solo el grosor delstroke, no hay que recortar nada). - Nueva devDependency
@xmldom/xmldom: usada solo encharts.test.tspara parsear el SVG generado y confirmar que es XML "well-formed" (lanza enfatalError, p. ej. tags sin cerrar) — no se agregó a dependencies porquecharts.tsnunca la importa en tiempo de ejecución. - Nueva dependency
handlebars(prevista en la tabla de stack tecnológico de este documento, instalada recién en esta fase). .prettierignoreagregatemplates/**/*.hbs: no hay plugin de Prettier para Handlebars instalado, y el parser HTML por defecto de Prettier no reconoce{{...}}(falla conSyntaxErroren cualquier expresión o partial). El linting/formato de.tsdecore/reportsí corre normalmente poreslint/prettier.
- Nuevas dependencies:
zod(prevista en la tabla de stack),pino+pino-pretty(prevista, "Logging"),commander(prevista, "CLI framework"),open(prevista, para abrir el navegador).openqueda instalada pero SIN USO todavía: en esta fase no existe un servidor real que abrir (eso es fase 5,adapters/server, y es ahí dondeconfig.server.openBrowsertiene efecto). Instalarla ahora en vez de en fase 5 es simplemente seguir el orden en que la consigna de esta fase la pidió; no se fuerza su uso prematuro eninit/run/reportpara no abrir un navegador contra un reporte estático sin que el usuario lo haya pedido. QaConfigSchemausa.prefault({}), no.default({}), en los tres objetos anidados (server/evidence/logging): se verificó a mano (antes de escribircore/types/config.ts) que en Zod v4.default(valor)sustituye el inputundefinedporvalorTAL CUAL, sin volver a pasarlo por el schema envuelto —z.object({ port: z.number().default(3000) }).default({})parseado sobreundefinedda{}, no{ port: 3000 }. Esto rompía exactamente el caso "config parcial" pedido por la consigna (p. ej.{ "server": { "port": 5000 } }sinopenBrowserdebe completarse con el defaulttruedeopenBrowser)..prefault(valor)sí vuelve a corrervalorpor el schema interno, aplicando sus propios defaults por campo, y es lo que finalmente se usó. Los campos hoja (strings/arrays/enums que no tienen a su vez sub-campos con defaults) siguen usando.default()normal, donde el comportamiento no importa.projectNameNO es obligatorio a nivel esquema: tiene un default genérico (DEFAULT_PROJECT_NAME = 'Mi Proyecto QA'). Se decidió así (en vez dez.string()sin default, que haría fallarQaConfigSchema.parse({})) porqueloadConfig()debe poder validar una config parcial escrita a mano sinprojectNamesin que eso sea, por sí solo, un error de validación — el nombre "genérico" resultante es una degradación aceptable (se muestra en el reporte), no una condición que deba bloquearrun/report. En la práctica esto casi no se ejerce:initsiempre escribeprojectNameexplícito (del nombre de carpeta o--name).loadConfigNUNCA asume defaults silenciosos siqa-config.jsonno existe: se decidió que sea estricto (lanzaConfigNotFoundError, ver próximo punto) en vez de devolverQaConfigSchema.parse({}), con el mismo criterio queSessionEngine.load()(SessionNotFoundError, fase 1/2): un archivo de config faltante casi siempre significa "todavía no corristeinit", y un fallback silencioso a defaults haría querun/reportcorran igual pero potencialmente apuntando a rutas equivocadas (p. ej. si el usuario ejecuta el comando desde el directorio equivocado) sin ninguna señal de que algo no se inicializó.- Nueva clase de error
ConfigNotFoundError extends QaError, ADEMÁS deConfigValidationError(la única prevista explícitamente para esta fase en la sección "Contratos principales" de este documento). Se agregó porque el caso "el archivo no existe" y "el archivo existe pero su contenido es inválido" necesitan mensajes accionables distintos ("correéinit" vs. "corregí este campo"), y el resto del código base ya sigue ese patrón (SessionNotFoundErrores una clase separada de cualquier error de "sesión con datos inválidos"). No se considera una desviación de la lista de errores de este documento, sino una extensión consistente con su propio criterio de diseño. createConfigLoader(deps?)sigue el mismo patrón de factory quecreateGherkinParser/createSessionEngine/createEvidenceStore/createReportGenerator(en vez de una función sueltaloadConfig(path)), por consistencia con el resto decore/**y para poder inyectarreadFileen tests sin tocar el filesystem real (mismo patrón queGherkinParserDeps.readFile).- Interfaz
Loggerencore/types/logger.ts(archivo nuevo, no mencionado explícitamente en la sección "Contratos principales" de este documento — se decidió agregarlo ahí, junto al resto de puertos decore/**, en vez de inline dentro decore/logger/, para que módulos hermanos comocore/parserpuedan importar el TIPO sin depender de la implementación concreta basada enpino). Expone únicamentedebug/info/warn/error(message, meta?)— un subconjunto deliberado de los niveles depino(sintrace/fatal), alineado 1:1 conLOG_LEVELS/LogLeveldecore/types/config.ts. GherkinParserDeps.logger(fase 1) se consolidó contraLoggerreal: pasó de una forma ad-hoc local ({ debug(message, meta?): void }) aPick<Logger, 'debug'>, importando el tipo real decore/types/logger.ts. Es un cambio compatible con cualquier caller existente (ningún test decore/parserpasabalogger) y con cualquierLoggerreal construido porcreateLogger(que sigue cumpliendoPick<Logger, 'debug'>al ser un superset).createLogger(level, deps?)acepta undeps.destinationinyectable: cuando se provee,pinoescribe ahí SIN el transportpino-pretty(líneas JSON crudas, más fáciles de parsear en un test) — cuando no se provee (caso de producción real), usapino-prettypara que la salida sea legible en una terminal interactiva. Los tests delogger.test.tssiempre inyectandestination(unWritableen memoria) para no depender deprocess.stdoutreal ni levantar el worker thread depino-pretty(que esta interfazLoggerno expone ninguna forma de cerrar explícitamente).- Distinción
Logger(diagnóstico) vs.print/console.log(salida de cara al usuario) enadapters/cli: los 3 comandos aceptan tantodeps.logger(nivel configurable, pensado para diagnóstico/debug — "Config cargada", "Features cargadas", etc.) comodeps.print(siempre visible, es la salida real que un QA lee en su terminal: próximos pasos deinit, resumen derun, ruta del reporte dereport). No están unificados en un solo canal porque tienen audiencias y ciclos de vida distintos:printes la interfaz de usuario del comando (se testea con asserts sobre el contenido, vercommands/*.test.ts) yloggeres observabilidad interna cuyo nivel se configura enqa-config.json(un QA normal corriendo conlogging.level: "info"no necesita ver "Config cargada" endebug, pero sí necesita ver "Reporte generado en ..."). - Comandos de CLI como funciones puras en
adapters/cli/commands/*.ts(runInit/runRun/runReport),adapters/cli/index.tssolo los conecta acommander: permite testearlos llamándolos directamente (inyectandocwd,logger,print) sin pasar por parsing deargvni por un subproceso real. Los tests de integración ligeros de esta fase (tests deinit/run/report) quedaron colocados junto a cada comando como*.test.ts(adapters/cli/commands/init.test.ts, etc.), siguiendo la misma convención que el resto decore/**("unit tests viven junto a cada módulo") en vez de entests/— se los consideró "unitarios de un comando" más que "end-to-end de todo el sistema" (eso último, conadapters/serverreal, queda para fase 6). - Condiciones de error puramente de
adapters/cli(nunca lanzadas porcore/**) usanQaErrorinstanciada directamente, en vez de una subclase dedicada por caso:CONFIG_ALREADY_EXISTS(initsin--forcesobre un proyecto ya inicializado),FEATURES_DIR_NOT_FOUND(runconfeaturesDirinexistente) yNOTHING_TO_REPORT(reportsin sesión guardada — envuelve elSessionNotFoundErrororiginal encause, con un mensaje más específico para este comando).QaErrorno es abstracta precisamente para permitir esto sin agregar tres clases de una sola línea acore/types/errors.tspara condiciones que solo existen a nivel de este adapter. initdeja un feature de ejemplo real (features/example.feature) en vez de un.gitkeep, pero SÍ usa.gitkeepenevidence//reports/: un QA nuevo abrefeatures/primero, y un.featurereal (comentado, editable/borrable) enseña el formato mejor que una carpeta vacía; no tiene sentido un "reporte" o una "evidencia" de ejemplo en las otras dos carpetas.adapters/cli/templatePaths.tsresuelveDEFAULT_TEMPLATE_DIRconfileURLToPath(import.meta.url)+ 3 niveles hacia arriba, nunca conprocess.cwd(): el comandoreportcorre con el cwd del proyecto del QA, sin relación con dónde está instaladoqa-evidence-reporter. Comotsconfig.jsonmantienedist/como espejo exacto desrc/(mismorootDir/outDirde fase 1), el archivo queda siempre exactamente 3 niveles bajo la raíz del paquete tanto ensrc/adapters/cli/templatePaths.ts(tests corriendo sobre TS) como endist/adapters/cli/templatePaths.js(binario real ya compilado) — la misma cuenta de..funciona en ambos casos.- Se agregó
"files": ["dist", "templates"]apackage.json: no existía ningún campofilesdesde fase 1. Sin él,npm publishincluye todo lo no ignorado por.gitignore(que hoy también cubriríatemplates/), pero dejarlo implícito es fragil ante cambios futuros de.gitignore; conreportdependiendo de quetemplates/viaje siempre junto al paquete publicado (ver punto anterior), se decidió declararlo explícitamente. - Corregida (post-fase 4, antes de iniciar fase 5) la limitación heredada
de fase 2 descrita originalmente en este punto:
EvidenceStore(core/evidence/evidenceStore.ts) anteponía SIEMPRE el segmento literal"evidence"a subaseDir, ignorandoconfig.evidenceDir. Se cambió el contrato:baseDirahora ES directamente la carpeta raíz de evidencias ya resuelta por el caller (evidenceRoot = baseDir, sinjoin(baseDir, 'evidence')interno) —core/evidencesigue sin conocerqa-config.json, pero deja de asumir un nombre de carpeta fijo. En consecuencia,EvidenceFile.path/.thumbnailPathya no llevan el prefijo"evidence/"(son relativos abaseDirtal cual), yreport(adapters/cli/commands/report.ts) pasaevidenceBaseDir: resolve(cwd, config.evidenceDir)— antes pasabacwda secas.config.evidenceDirahora tiene efecto real de punta a punta. Se corrigió en este punto (y no se dejó para fase 5) porque el server de fase 5 iba a heredar y agravar la misma inconsistencia al construir su propioEvidenceStorepara los uploads reales. runNO levanta ningún servidor HTTP real todavía (no existeadapters/serverhasta fase 5): carga config + parsea features + carga/detecta ausencia de sesión existente, e imprime un resumen — deja un mensaje explícito de que el servidor interactivo llega en la siguiente fase. Toda esa lógica de carga (la única responsabilidad real de esta fase) queda completamente testeada enrun.test.ts, para que fase 5 solo tenga que conectar el servidor encima sin reescribir nada de esto.
SessionEngine.removeEvidenceYA EXISTÍA desde fase 2 — no fue necesario agregarlo. La consigna original de esta fase asumía que podía ser "un hueco real de fase 2" a completar recién ahora, pero al revisarcore/types/session.ts(interfazSessionEngine) ycore/session/sessionEngine.ts(implementación) antes de tocar nada, el método ya estaba completo y con test propio (sessionEngine.test.ts,"addEvidence es idempotente y removeEvidence quita el id"). Se deja esta aclaración explícita porque el encargo de fase 5a llegó con la instrucción de "implementarlo si no existe" — se verificó primero en el código real (no en la documentación) y no hizo falta ningún cambio encore/session/**.DELETE /api/session/step/:stepId/evidence/:evidenceId(ver más abajo) usa este método preexistente tal cual.- Endpoints finales implementados (coincide con la lista original de
ARCHITECTURE.md salvo el matiz explícito en
GET /api/features, ver siguiente punto):GET /api/featuresPOST /api/session/select(query opcional?force=true)GET /api/sessionPOST /api/session/step/:stepId/evidence(multipart, campofiles, uno o varios archivos)DELETE /api/session/step/:stepId/evidence/:evidenceIdPOST /api/session/step/:stepId/resultPOST /api/session/navigatePOST /api/report/generateGET /api/report/export-zip- Estáticos:
/evidence-files/*→evidenceBaseDir,/reports-static/*→reportsDir,/(+ fallback SPA) → build deui/o placeholder.
GET /api/featuresNO crea una sesión automáticamente, a diferencia de la redacción original ("crea sesión si no existe" en la sección "API REST del server" de este documento). Es de solo lectura: devuelve las features encontradas enfeaturesDirmás{ exists: false }si no hay sesión, o{ exists: true, status, projectName }si la hay. Crear la sesión es una acción explícita del QA (qué features correr), modelada como su propio endpoint (POST /api/session/select) — auto-crearla con TODAS las features en unGETno tiene sentido con ese flujo de selección explícita que pide la UI (ARCHITECTURE.md, "UX del runner").- Id de feature antes de seleccionarla (
GET /api/features/POST /api/session/select): la ruta relativa del.featurerespecto afeaturesDir(siempre con/, versessionQueries.ts,buildFeatureRefId), NO elFeatureExecution.iddecore/session("f{featureIndex}-{slug}"). Ese id de sesión recién existe una vez creada la sesión (depende de la POSICIÓN dentro de las features YA seleccionadas) — antes de seleccionar, todas las features disponibles necesitan un identificador propio, estable mientras el archivo no se mueva/renombre. POST /api/session/selectsobre una sesión en curso: si existe una sesión constatus !== 'completed', responde409(SESSION_ALREADY_IN_PROGRESS) salvo que se pase?force=true. Si la sesión existente ya está'completed', se permite reseleccionar sin pedir confirmación (no hay progreso sin terminar que se pueda perder). Se eligió el query param de confirmación explícita (en vez de permitir pisar sin más, o de bloquear sin salida) porque re-enviar la pantalla de selección de la UI no debería destruir evidencia/resultados ya cargados por accidente, pero tampoco debe haber ningún estado del que no se pueda salir.- Contexto para
EvidenceStore.savesobre unstepIdarbitrario:SessionEnginesolo expone la ternafeatureId/scenarioIdpara el step decurrentPosition(getCurrentStep()) — deliberado, ver su JSDoc encore/types/session.ts. Pero la UX de "volver a un step anterior para adjuntar/editar evidencia" implica quePOST /api/session/step/:stepId/evidencepuede apuntar a un step que no es el actual. En vez de ensanchar el contrato deSessionEnginepara un caso de uso de transporte,sessionQueries.ts(findStepContext) recorreSessionState(estructura pública) con el mismo patrón que ya usacore/report/reportGenerator.tspara recorrer ese mismo árbol. - Multer con
memoryStorage(), nodiskStorage(), y SINlimits.fileSizeconfigurado: los archivos de evidencia de una sesión de QA manual local (un solo usuario, sin adversario remoto) son screenshots/videos cortos/PDFs, casi siempre bien por debajo del default deevidence.maxFileSizeMB(50MB).memoryStorageevita limpiar archivos temporales huérfanos cuando la validación de tamaño/formato rechaza el archivo después de que multer ya lo recibió, y evita una segunda copia de I/O (temporal → destino final). No fijarlimits.fileSizees deliberado: se prefiere dejar que el buffer llegue completo y comparar su tamaño REAL contraconfig.evidence.maxFileSizeMBa mano (en la ruta), para poder lanzarEvidenceFileTooLargeErrorcon el tamaño exacto del archivo rechazado, en vez de que multer aborte la conexión con su propio error genérico sin ese detalle. Todo el lote de archivos de una misma solicitud se valida (formato + tamaño) ANTES de guardar cualquiera, para no dejar guardados parciales de un lote rechazado a mitad de camino. - Nueva clase de error
UnsupportedEvidenceFormatErrorencore/types/errors.ts(prevista explícitamente en la sección "Contratos principales" de este documento, pendiente desde fase 2/3/4 — ver el comentario que dejaba ese hueco marcado encore/types/errors.ts). Mismo criterio queEvidenceFileTooLargeError: vive en el dominio compartido porque es una condición de negocio ("formato no permitido") más allá de un solo adapter, aunque quien la lanza es únicamenteadapters/server(el único módulo que cruza el archivo subido contraqa-config.json→evidence.allowedFormats). - Mapa código→status HTTP centralizado en
adapters/server/errors.ts(ERROR_STATUS_BY_CODE, constatusForErrorCodecomo único punto de lectura):FEATURE_PARSE_ERROR→400,SESSION_NOT_FOUND→404,INVALID_STEP_TRANSITION→400,EVIDENCE_FILE_TOO_LARGE→413,UNSUPPORTED_EVIDENCE_FORMAT→415,REPORT_GENERATION_ERROR→500,CONFIG_VALIDATION_ERROR→400,CONFIG_NOT_FOUND→404, más los códigos que solo existen en este adapter (mismo criterio de fase 4 para condiciones puramente de adapter —QaErrorinstanciada directamente, sin subclase dedicada):SESSION_ALREADY_IN_PROGRESS→409,NOTHING_TO_REPORT→404 (mismo código/criterio que ya usabareport.tsdeadapters/cli, reimplementado localmente porqueadapters/serverno puede importar deadapters/cli),NO_REPORT_GENERATED→404,FEATURE_NOT_FOUND→400,INVALID_REQUEST_BODY→400. Cualquier código deQaErrorno listado cae a 500 (fallback conservador). Cualquier error que NO seaQaError(bug real) siempre responde 500 genérico (INTERNAL_SERVER_ERROR, sin stack ni mensaje interno) pero se loguea completo víalogger.error. ServerContextlleva rutas ya resueltas, no uncwdcrudo —mismo criterio que las dependencias de las factories decore/**(SessionEngineDeps, etc.):{ config, logger, projectRoot, sessionFilePath, featuresDir, evidenceBaseDir, reportsDir, templateDir }.createApp(context)construye sus propias instancias deSessionEngine/EvidenceStore/GherkinParser(una única vez, no por request — verservices.ts,CoreServices) a partir de esas rutas, igual que ya hacen los comandos deadapters/cli. Se agregóbuildServerContext(projectRoot, deps?)(context.ts) para armar unServerContextreal replicando la misma resolución de rutas querun.ts/report.ts; no está conectada todavía aqa-evidence-reporter run(ver el punto de fase 4 "runNO levanta ningún servidor HTTP real todavía" — sigue pendiente para fase 5b o un ajuste posterior, no bloqueante para esta fase). Se usó para la prueba manual de esta fase (levantarstartServerreal contra un proyecto de ejemplo).adapters/cli/templatePaths.tsse duplicó comoadapters/server/templatePaths.ts(mismo cálculo de 3 niveles hacia arriba desdeimport.meta.url, misma profundidad de archivo), y de la misma formaadapters/cli/fsUtils.ts→adapters/server/fsUtils.ts(pathExists) y el literal".qa-evidence-reporter"(nombre de la carpeta de sesión) se repite enadapters/server/context.ts: por la regla de dependencia estricta de este documento ("adapters/cli/**yadapters/server/**... nunca entre sí directamente"), se prefirió duplicar estos fragmentos triviales antes que crear un import cruzado entre adapters o forzar ese conocimiento de rutas de filesystem del paquete instalado haciacore/**(que no debería tenerlo).- Ruta esperada del build de
ui/(fase 5b):<raíz del paquete>/dist/ui(adapters/server/uiPaths.ts,UI_DIST_DIR), NOsrc/ui/dist. Motivo: (1) co-ubicación condist/ya declarado en"files"depackage.jsondesde fase 4 — cualquier cosa bajodist/se publica sin tocar ese campo; (2) resolución simétrica aDEFAULT_TEMPLATE_DIR(templatePaths.ts): mismo archivo, misma profundidad, misma cuenta de..hasta la raíz del paquete, tanto corriendo sobresrc/(tests) como sobredist/(binario compilado). Fase 5b debe configurarbuild.outDirde Vite (relativo asrc/ui/) como"../../dist/ui". Sidist/ui/index.htmlno existe (el caso normal durante toda esta fase),GET /y cualquier ruta no-/api/*devuelven un placeholder HTML fijo (UI_NOT_BUILT_PLACEHOLDER_HTML) en vez de un 404 o un crash deexpress.staticcontra un directorio inexistente. archiver@8(la versión que instalónpm install archiveral momento de esta fase) reescribió su API pública a clases ESM con nombre (ZipArchive/TarArchive/JsonArchive, todas extendiendoArchiver) en vez de la factory clásicaarchiver(format, options)de versiones anteriores — no hayexport defaultniexport =en@types/archiver@8.GET /api/report/export-zipusanew ZipArchive()como equivalente exacto para'zip'. Se documenta acá porque cualquier ejemplo/tutorial dearchivervisto antes de esta fecha probablemente muestra la API vieja.GET /api/report/export-zipempaqueta elreportsDirCOMPLETO tal cual está en disco en el momento de la llamada (el últimogenerate()ejecutado, sin importar cuándo) — no hay un estado "reporte pendiente de exportar" separado; simplemente se valida quereportsDir/index.htmlexista (si no,404 NO_REPORT_GENERATED) y se archiva el directorio entero conarchive.directory(reportsDir, false). Si el streaming del ZIP falla a mitad de camino (evento'error'dearchiver), ya no es posible convertir eso a un JSON{ error: {...} }limpio (los headers/parte del body ya se enviaron) — se loguea completo y se corta la conexión conres.destroy(error), en vez de reenviarlo alerrorHandlercentral (pensado solo para errores ANTES de empezar a escribir la respuesta).- Nueva dependency
express,multer,archiver(previstas en la tabla de stack tecnológico de ARCHITECTURE.md: "Server" y "ZIP export";multerno estaba en esa tabla pero es la elección estándar de Express paramultipart/form-data, mencionada explícitamente en la sección "API REST del server"). Nuevas devDependencies:@types/express,@types/multer,@types/archiver,supertest+@types/supertest(tests de integración de esta fase,app.test.ts). eslint.config.js:@typescript-eslint/no-unused-varsganó{ argsIgnorePattern: '^_' }para el bloque**/*.ts. Necesario porque Express reconoce el middleware de manejo de errores por aridad (debe declarar exactamente 4 parámetros —(err, req, res, next)— aunquereq/nextno se usen en el cuerpo, veradapters/server/errors.ts,createErrorHandler); sin este ajuste, el default (args: 'after-used') marcaría como error el último parámetro no usado. No afecta código de fases anteriores (ningún parámetro previo usaba el prefijo_).- Tests de integración con
supertestcontracreateApp()real, SIN levantar un puerto TCP (adapters/server/app.test.ts, 12 casos): flujo completo select→evidencia→resultado→navigate→completar sesión→generar reporte→exportar ZIP (verificando la firma de archivo"PK"del ZIP recibido), más los casos de error explícitos de la consigna (failsindefectDescription→400, archivo que excedemaxFileSizeMB→413, formato no permitido→415,DELETEde evidencia,409al reseleccionar una sesión en curso sinforce,404paraGET /api/session/POST /api/report/generate/GET /api/report/export-zipsin sesión/reporte). Además, la prueba manual obligatoria de esta fase (startServerreal en un puerto TCP real, contra un proyecto de ejemplo en un directorio temporal, golpeado concurlde punta a punta:GET /,GET /api/features,GET /api/session, selección de features, subida real de un archivo de evidencia con verificación en disco, marcado de resultados, navegación hasta completar la sesión, generación del reporte y descarga/validación del ZIP conunzip -l) se corrió y confirmó exitosa por fuera de la suite automatizada — no quedó como test de Vitest porque su propósito es específicamente validar el binario/proceso real escuchando un socket TCP real, algo quesupertestdeliberadamente evita.
- Scaffolding:
src/ui/con Vite (@preact/preset-vite) +preact.src/ui/vite.config.tsfijarootexplícitamente al propio directorio (fileURLToPath(new URL('.', import.meta.url)), noprocess.cwd()) ybuild.outDir: '../../dist/ui', que resuelve EXACTAMENTE aUI_DIST_DIR(adapters/server/uiPaths.ts) — verificado leyendo ese archivo antes de escribir la config, tal como pedía la consigna de esta fase, en vez de asumir la ruta de memoria.npm run build:uiprodujodist/ui/index.html+dist/ui/assets/*.{js,css}en la primera corrida, sin ajustes. Scripts nuevos enpackage.json:build:ui(vite build --config src/ui/vite.config.ts),dev:ui(vite --config src/ui/vite.config.ts, con proxy de/api,/evidence-filesy/reports-statichaciahttp://localhost:{QA_DEV_SERVER_PORT ?? 3000}— los mismos 3 prefijos no-/que montaapp.ts, verstaticPrefixes.ts), ybuildraíz pasó detscatsc && npm run build:ui(backend primero, UI después; no hay dependencia real entre ambos pasos, pero mantiene el orden "core primero" del resto del proyecto). tsconfig.jsonraíz agregasrc/ui/**aexclude(además de**/*.test.ts, ya existente): sin esto,tsc(raíz,module/moduleResolution: NodeNext) intentaba compilar también los.tssueltos desrc/ui/(api.ts,colors.ts,types.ts— los.tsxya quedaban afuera por extensión, pero los.tsplanos SÍ matcheabansrc/**/*.ts) y fallaba conTS2835("Relative import paths need explicit file extensions..."): el código deui/está escrito para el resolutor de módulos de Vite/esbuild (imports sin extensión,import type { X } from './types'), no paraNodeNext.ui/nunca se compila con eltscdel backend — Vite/esbuild la transpila directamente adist/uisin pasar portscen ningún punto del pipeline de build real (npm run build).src/ui/tsconfig.json(nuevo,jsx: "react-jsx",jsxImportSource: "preact",libcon DOM,moduleResolution: "Bundler") existe solo para que el editor/IDE tipe correctamente el código deui/y paranpx tsc --noEmit -p src/uimanual (corrido y verificado sin errores antes de cerrar esta fase) — no está conectado a ningún script depackage.json, exactamente como especifica la consigna (build:uies solovite build).- Regla de dependencia estricta, mitad de
ui/**, agregada aeslint.config.js: hasta esta fase,no-restricted-importscontracore/**/adapters/**solo estaba escrita parasrc/core/**(porquesrc/ui/no existía como código real todavía). Se agregó un bloque gemelo parasrc/ui/**/*.ts(x)que bloquea imports de**/core/**y**/adapters/**, haciendo cumplir en lint la otra mitad de la regla de ARCHITECTURE.md ("ui/**solo llama aadapters/servervíafetchHTTP. Nunca importacore"). Verificado manualmente que dispara (no-restricted-imports) agregando un import de prueba decore/types/session.jsa un archivo deui/y confirmando el error antes de revertirlo. - Tipos de
src/ui/types.tsDUPLICADOS decore/types/session.ts/evidence.ts/parser.ts, no importados: consecuencia directa de la regla de dependencia estricta (ni siquiera unimport typeestá permitido). Incluye tambiénderiveScenarioResult/deriveFeatureResult(recalculadas sobreSessionStatepúblico, misma tabla de prioridadfail > pending > skip > pass) y una función nueva,getCurrentStepFromSession(session), que reconstruyeCurrentStepInfoa partir deSessionState.currentPosition— necesaria porquePOST .../evidenceyDELETE .../evidence/:iddevuelven{ session }sin uncurrentStepya resuelto (a diferencia deselect/result/navigate), y subir/borrar evidencia nunca muevecurrentPosition, así que recalcularlo en el cliente evita una request extra solo para volver a pedir el step actual.src/ui/colors.tsduplica igualRESULT_COLORS/RESULT_LABELSdecore/report/charts.ts— mismo criterio ya usado en fase 3 paratemplates/default/partials/styles.hbs(comentario cruzado en ambos archivos para que no diverjan silenciosamente). - Endpoint nuevo, único cambio real a
adapters/serverde esta fase:GET /api/session/step/:stepId/evidence(routes/session.ts), fuera de la lista de endpoints de ARCHITECTURE.md ("Fase 5a"). Se agregó como excepción explícita a "no modificaradapters/serversalvo un bug bloqueante":StepExecution(core/types/session.ts) solo guardaevidenceFileIds: string[], nunca la metadata completa (kind/thumbnailPath/sizeBytes/originalFilename) — antes de esta fase, la ÚNICA forma de que la UI conociera esa metadata era leer la respuesta dePOST .../evidenceen el momento mismo de subir el archivo. Eso bloqueaba por completo el requisito de UX "preview de evidencias ya adjuntadas" en cualquier otro momento (recargar la página, o el flujo explícitamente pedido en esta fase de "continuar una sesión existente" sin volver a subir nada). El único ingrediente decore/**que hacía falta,EvidenceStore.list(stepId), ya existía completo desde fase 2 (reconstruye la metadata escaneando el filesystem) — la ruta nueva es una capa HTTP fina sobre algo ya implementado y testeado, sin tocarcore/**. Se agregaron 2 casos aapp.test.ts(200 con metadata completa incluyendothumbnailPath, y 400INVALID_STEP_TRANSITIONpara unstepIdinexistente) — la suite pasó de 12 a 14 tests. Verificado además concurlcontra un server real (ver más abajo). app.test.ts, caso "GET /responde el placeholder..." reescrito para no asumir un único resultado fijo: ese test (fase 5a) asumía quedist/ui/index.htmlnunca existe, lo cual dejó de ser universalmente cierto exactamente a partir de esta fase (npm run build/build:uireal ahora lo crea).UI_DIST_DIRes una ruta fija del paquete instalado, no inyectable víaServerContextcomo el resto de rutas de este archivo de test (que sí usanprojectRoottemporal aislado) — no hay forma de parametrizarla sin tocaruiPaths.ts/app.ts, algo que la consigna de esta fase no pedía y que hubiera sido un cambio de comportamiento real, no un bugfix. Se cambió el test para que verifique la respuesta CONSISTENTE con el estado real dedist/uien el momento de correr (<div id="app">y ausencia del texto del placeholder si el build existe; el placeholder si no) — exactamente la lógica real deuiBuildExistsenapp.ts, en vez de fingir un estado de filesystem que ya no es el normal del repo tras esta fase.- Nuevas dependencies:
preact(prevista en la tabla de stack tecnológico). Nuevas devDependencies:vite,@preact/preset-vite,jsdom,@testing-library/preact,@testing-library/jest-dom(pedidas explícitamente por la consigna de testing de esta fase).@testing-library/user-eventse instaló pero terminó sin uso real (los tests de esta fase cubrieron interacción confireEvent— click, change, drop, paste — sin necesitar su simulación de más alto nivel) y se desinstaló antes de cerrar la fase, para no dejar una dependencia muerta. - Tests de componentes:
vitestcon// @vitest-environment jsdompor archivo (no unenvironment: 'jsdom'global en unvitest.config.tsnuevo — este proyecto no tenía ninguno hasta ahora): así los tests decore/**/adapters/**(Node puro) no pagan el costo de levantar jsdom, y cada archivo desrc/ui/**/*.test.tsxdeclara explícitamente que lo necesita.src/ui/api.test.tses la única excepción real: corre en el entorno Node default (sin el pragma) porque solo ejercitafetchmockeado, y Node 18+/22 ya exponefetch/File/FormDatanativos (no hace falta DOM para eso). @testing-library/preactrequiere registrarafterEach(cleanup)a mano en cada archivo de test que renderiza algo: su auto-cleanup (dist/esm/index.mjs) solo se activa si detecta unafterEachGLOBAL (typeof afterEach === 'function'sin import), y este proyecto no habilitatest.globalsen Vitest (para no filtrar globals de test hacia el resto de la suite, que corre en Node puro). Sin este registro manual, el segundo test de un archivo que renderiza dos veces el mismorole(p. ej. dos botones con el mismo texto de dos renders no limpiados) fallaba con "Found multiple elements" — encontrado y corregido durante esta fase agregandoafterEach(cleanup)explícito en los 5 archivos de test que usanrender/renderHook.- Verificación de UI, en el orden de preferencia pedido por la consigna:
- (a) Navegador real: NO había ninguna herramienta
claude-in-chromeconectada en este entorno (se verificó buscándola explícitamente antes de intentar usarla) — se pasó directamente a (b)/(c) sin fingir una verificación visual que no ocurrió. - (b) Tests de componentes (
vitest+@testing-library/preact+ jsdom): 31 tests nuevos en 6 archivos (FeatureSelect,StepResultPanel,EvidenceArea,ThemeToggle+useTheme,useKeyboardShortcuts,api), cubriendo: render de la lista de features + selección + banner de "continuar sesión"; render de los 3 botones de resultado; el botón "Fail" deshabilitado mientrasdefectDescriptionesté vacío/solo espacios y habilitado con texto; los 5 atajos de teclado (P/F/S/N/B, case-insensitive) disparando su handler y siendo ignorados por completo con el foco en un<input>/<textarea>; adjuntar un archivo simulado (file input, drag&drop, paste fuera de un campo de texto, paste ignorado dentro de uno) disparandoonUpload/la llamadafetchesperada (fetchglobal mockeado enapi.test.ts, verificando método, URL y elFormDatareal incluyendo múltiples archivos bajo el campo"files"); toggle de tema con persistencia enlocalStoragey precedenciaprefers-color-schemesolo cuando no hay elección explícita guardada. - (c) Integración real sin navegador: con
npm run build(tsc +vite build) ya corrido, se levantóstartServer/buildServerContextreales (compilados endist/) contra un proyecto temporal con una feature real (/tmp/.../curl-project), y se ejercitó concurlel flujo COMPLETO:GET /devuelve elindex.htmlreal de la SPA (<div id="app">, sin el texto del placeholder) con sus<script>/<link>de assets con hash (/assets/index-*.js,/assets/index-*.css), ambos verificados con200/content-type correcto; una ruta de cliente arbitraria (/some/client/route) también devuelve200con elindex.html( fallback de SPA);/api/nopesigue devolviendo404JSON (no HTML); selección de features → subida real de un archivo PNG real (no un mock) →GET /api/session/step/:stepId/evidence(el endpoint nuevo de esta fase) devolviendo la metadata completa conthumbnailPathreal → el thumbnail servido como estático (/evidence-files/...thumb.png,200,image/png) → marcar pass/skip/fail (condefectDescription) → sesión completa → generar reporte →GET /reports-static/index.html(200) → exportar ZIP y verificar conunzip -lque contieneindex.html, el HTML de detalle de la feature, y los archivos de evidencia + thumbnail reales.
- (a) Navegador real: NO había ninguna herramienta
adapters/clisigue sin levantarstartServer(el punto ya dejado pendiente explícitamente en fase 5a, "Fase 5a o un ajuste posterior"): fuera del alcance de esta fase (ui/puramente frontend, sin tocaradapters/cli/adapters/serversalvo el endpoint de evidencia ya documentado arriba).qa-evidence-reporter runsigue solo imprimiendo un resumen; conectarruna un server real con la UI ya construida queda para una fase de pulido posterior (fase 6, según el orden de construcción de este documento) o un ajuste dedicado.
- Excepción deliberada y documentada a la "Regla de dependencia estricta"
(sección inicial de este documento: "
adapters/cli/**yadapters/server/**... nunca entre sí directamente"):adapters/cli/commands/run.tsahora importastartServer/ServerContext/StartServerResultdeadapters/server/index.ts. Esta regla existía para evitar acoplar los dos adapters de transporte cuando no había ninguna razón real para que se conocieran entre sí (fase 5a duplicó a propósito fragmentos triviales —templatePaths.ts,fsUtils.ts— antes que violar la regla). Fase 6 es distinta: la ÚNICA razón de ser deqa-evidence-reporter runpasa a ser levantar ese server real (ver el siguiente punto) — sin importarlo, sería imposible cumplir el requisito original del proyecto ("runabre/levanta el server con la UI funcional real"). Se decidió importarstartServertal cual (no reimplementar su lógica de arranque/apertura de navegador enadapters/cli, que hubiera sido la alternativa para "no violar la regla") porque hubiera duplicado código no trivial (manejo de errores delisten, resolución del puerto real, la lógica deopen()con su propio try/catch) que ya está escrito y testeado en fase 5a. No se tocóeslint.config.js: no existía ninguna reglano-restricted-importsentreadapters/cli/**yadapters/server/**(solo entrecore/**/ui/**y el resto, ver fases 1 y 5b) — la regla de este documento era una convención sin verificación automática en lint para este par específico, y sigue así; si una fase futura quisiera revertir esta excepción, tendría que reintroducir la duplicación de fase 5a. runRunconecta con el server real: construye unServerContextcon las MISMAS rutas ya resueltas que el resto de esta función usaba para su resumen (featuresDir,evidenceBaseDir = resolve(cwd, config.evidenceDir)— la corrección de fase 4 —,reportsDir,sessionFilePath) mástemplateDir(una copia de la funciónresolveTemplateDirdereport.ts, duplicada localmente enrun.tspor el mismo motivo queadapters/server/templatePaths.tsdocumenta para su propia copia: es un cálculo trivial, y no vale la pena ensanchar más la superficie compartida entre adapters por esto), y llama astartServer(context). No se duplicó la lógica de "abrir el navegador siconfig.server.openBrowser":startServer(fase 5a) YA la implementa completa, con su propio try/catch que jamás hace fallar el arranque del server siopen()falla (p. ej. sin entorno gráfico) — solo loguea unwarn. Se verificó esto LEYENDOadapters/server/index.tsantes de escribir código (tal como pedía la consigna de esta fase) en vez de asumir que había que reimplementarlo enrun.ts; duplicar esa lógica en dos lugares hubiera sido la fuente de bugs más probable de esta fase (p. ej. abrir el navegador dos veces).- Ciclo de vida del proceso (
SIGINT/SIGTERM→ cierre limpio):runRunno fuerzaprocess.exit()en ningún punto. En vez de eso,awaituna promesa (waitForShutdownSignal, inyectable — ver siguiente punto) que se resuelve cuando llegaSIGINToSIGTERM, y solo entonces llama aclose()(elcloseque devuelvestartServer) antes de querunRunretorne. Como el servidor HTTP escuchando un socket TCP ya mantiene vivo el event loop de Node por sí solo, no hace falta ningún mecanismo adicional para "mantener el proceso vivo" — simplemente no se retorna derunRunhasta que el usuario interrumpe. Una vez queclose()resuelve y no queda ningún handle abierto, el proceso termina solo (comportamiento normal de Node), sin necesitar--no-verifyni matar el proceso a la fuerza — se verificó esto manualmente contra el binario compilado real (ver la validación de esta fase, punto "empaquetado/instalación aislada"). RunCommandDepsganastartServerywaitForShutdownSignalinyectables (mismo patróndepsque ya usaninit/reportdesde fase 4): el default de producción destartServeres el real deadapters/server; el default dewaitForShutdownSignalregistra listeners reales deprocessparaSIGINT/SIGTERM(conprocess.once, limpiando el listener que no disparó apenas se resuelve, para no dejar un listener huérfano). Los tests derun.test.tsinyectan ambos con fakes que nunca abren un socket TCP real ni esperan una señal real de proceso — excepto un test dedicado ("usa startServer real de adapters/server por defecto") que deliberadamente NO inyectastartServer, para probar que el default de producción funciona de punta a punta (abriendo un socket TCP real en un puerto fijo poco común, conopenBrowser: false), inyectando solowaitForShutdownSignalpara que el test no quede colgado esperando una señal real. La consigna original también sugería un dep inyectable paraopen: no se agregó por separado, porquerun.tsnunca llama aopen()directamente (esa responsabilidad ya es 100% destartServer, ver el punto anterior) — inyectar unopenenRunCommandDepsque nunca se usa hubiera sido una dependencia decorativa sin ningún efecto real que testear desde este módulo.sample-project/: 3.featurereales (login.featureen español con# language: es,busqueda.featureen inglés,carrito-compras.featureen español conAntecedentes/Backgroundy unEsquema del escenario/Scenario Outlinecon 2 filas deEjemplos/Examples), sumando 12 scenarios concretos (3 + 5 + 4, contando cada fila de Outline expandida como su propio scenario) y 52 steps en total.qa-config.jsoncon un nombre de proyecto y equipo de ejemplo realistas.sample-project/simulate-session.mjs: script standalone (node sample-project/simulate-session.mjs, sin flags) que usa la API HTTP REAL del server (startServer/buildServerContext, importados desdedist/adapters/server/index.js— requierenpm run buildcorrido antes) para simular una sesión de punta a punta: selecciona las 3 features, adjunta evidencia real (PNG generado conjimp, sin binarios nativos, el mismo patrón que ya usaapp.test.ts) en el primer step y en el primer step marcadofail/skipde cada corrida, marca una mezcla determinística de pass/fail(condefectDescription)/skip (regla fija: cada 7mo stepskip, cada 5tofail, el restopass), navega hasta completar la sesión, genera el reporte y exporta el ZIP. Decisión de diseño (dónde queda todo lo generado): el script NUNCA escribe dentro desample-project/— cada corrida crea un directorio de trabajo nuevo y aislado bajoos.tmpdir()(qa-evidence-reporter-sample-<random>/), con su propioqa-config.jsoncuyofeaturesDires la ruta ABSOLUTA asample-project/features(evita depender de quéprojectRootse le pase abuildServerContextpara encontrar las features reales, que son de solo lectura) mientras queevidenceDir/reportsDir/.qa-evidence-reporter/quedan relativos a ese directorio de trabajo. Así, correr el script muchas veces nunca ensucia el repo ni dos corridas pueden pisarse. El script imprime la ruta completa del directorio de trabajo, del reporte HTML y del ZIP al final. Verificado corriendo el script real trasnpm run build: 52 steps ejecutados (36 pass / 9 fail / 7 skip), reporte HTML con el defecto simulado embebido, ZIP de 20 archivos válido (unzip -l).tests/e2e.test.ts: usacreateGherkinParserreal sobresample-project/features/(sin mocks) para confirmar que las 3 features parsean con el contenido esperado (idiomas, tags, cantidad de scenarios expandidos desde losScenario Outline), ycreateAppreal (misma factory que usastartServer/run.ts) víasupertest— mismo patrón queadapters/server/app.test.ts(fase 5a) pero sobre las features reales del sample project — para ejercitar selección → evidencia real → resultados mixtos (pass/fail con defecto/skip) → navegación (incluyendo hacia atrás sobre una sesión ya completada) → generación del reporte → export ZIP. Verifica el HTML generado en disco (nombres reales de features, badges de estado incluyendoqa-badge--fail/"Fallido", el texto del defecto simulado, ausencia de cualquiersrc=/href=externo para reconfirmar que el reporte funciona offline) y el ZIP exportado descomprimiéndolo de verdad con el binariounzipdel sistema (unzip -l/unzip -p, mismas flags que la verificación manual de fase 5a) — no se agregó ninguna devDependency nueva de lectura de ZIP porqueunzipya es la herramienta que este proyecto usa para esa verificación. Un tercer test ("permite pausar y retomar una sesión...") crea DOS instancias independientes decreateAppsobre el mismoServerContext(el mismosessionFilePath): la segunda instancia nunca cargó nada en memoria, así que si el estado (posición actual, resultado ya asignado, evidencia ya subida) sobrevive al consultarla, es una prueba real de autosave +SessionEngine.load()— no un mock — de "cerrar y retomar" (ver el ítem del checklist original). Suite completa: 141 tests (138 previos + 3 nuevos de este archivo; los 9 derun.test.tsreemplazan/extienden los 4 anteriores de esa fase, ver el punto de arriba).README.mdnuevo en la raíz (no existía ninguno hasta esta fase): instalación, flujoinit→.feature→run→report, tabla de atajos de teclado, tabla de campos deqa-config.json, y una sección de arquitectura deliberadamente breve que remite a este documento para el detalle completo (no se duplicó contenido).- Verificación de empaquetado/instalación real, en un prefix npm AISLADO
(nunca la instalación global real del sistema):
npm packen la raíz +npm install -g --prefix <scratchpad>/fake-global <tarball>+ ejercitar el binario resultante (PATHcon ese prefix anexado solo para los comandos de verificación) en una carpeta de trabajo temporal separada:init→ copiarsample-project/features/login.feature→ editarqa-config.jsongenerado paraopenBrowser: false→runen background →curlconfirma que sirve la SPA real dedist/ui(no el placeholder,<div id="app">con assets con hash) y queGET /api/featuresdevuelve la feature copiada →SIGINTal proceso (nokill -9) → el proceso termina solo (confirmando el cierre limpio del punto anterior) →reportgenera el HTML. Usa EXCLUSIVAMENTE lo que viaja en el tarball (dist/,templates/,package.json— el campofilesya declarado desde fase 4), nunca archivos sueltos desrc/del repo fuente. Ver el reporte de cierre de esta fase para los comandos y la salida real completa.
Reportado por un usuario real probando la UI en vivo: al adjuntar una
evidencia por error y borrarla con el botón "×", la evidencia volvía a
aparecer. Causa raíz confirmada leyendo el código (no era un malentendido de
UI): SessionEngine.removeEvidence (fase 2) solo quitaba el id de
step.evidenceFileIds en session.json, pero EvidenceStore (también fase
2) NUNCA tuvo un método para borrar el archivo físico — la fase 5a incluso
documentó esto explícitamente como comportamiento aceptado en un test
("DELETE evidencia la quita de la lista del step SIN borrar el archivo físico"). Esto quedó oculto hasta la fase 5b, cuando la UI empezó a llamar
a GET /api/session/step/:id/evidence para refrescar previews — ese
endpoint usa EvidenceStore.list(), que reconstruye la lista ESCANEANDO EL
FILESYSTEM (fuente de verdad independiente de session.json, ver su JSDoc
original) — así que el archivo, todavía físicamente presente, volvía a
aparecer en cada refresh sin importar qué dijera session.json.
Corrección:
EvidenceStore(interfaz encore/types/evidence.ts+ implementación encore/evidence/evidenceStore.ts) gana un método nuevo:remove(stepId, evidenceFileId): Promise<void>, que localiza el archivo reutilizando el mismo escaneo quelist/getThumbnaily borra el original y su thumbnail (unlink, ignorandoENOENT— no-op/idempotente si ya no existe, mismo criterio de "best effort" que ya usatryGenerateThumbnail).DELETE /api/session/step/:stepId/evidence/:evidenceId(adapters/server/routes/session.ts) ahora llama aevidenceStore.remove(...)ANTES desessionEngine.removeEvidence(...)— en ese orden deliberado: si el borrado físico fallara, se prefiere dejar una referencia "huérfana" pero recuperable ensession.jsonantes que una sesión que ya no referencia un archivo que sigue ocupando disco.- Se corrigió el test de fase 5a que documentaba el bug como comportamiento
esperado, y se agregaron tests dedicados de
EvidenceStore.remove(borrado real, no reaparece enlist(), no-op sobre id inexistente, idempotencia) y un test de integración HTTP que reproduce el flujo completo (subir → borrar → refrescar lista → confirmar vacía → borrar de nuevo sin error). - Verificado además manualmente contra el binario compilado real
(
dist/), reproduciendo el flujo exacto reportado (subir evidencia, borrarla, listar de nuevo) víacurlsobre un server real.
Pedido real de un usuario para poder compartir el reporte con clientes con
identidad visual propia. Alcance: reporte HTML (fase 3) Y runner (fase 5b),
ambos opcionales vía qa-config.json → branding (ver sección "Formato de
qa-config.json" arriba).
- Decisión de diseño (colores semánticos de resultado INTOCABLES):
pass/fail/skip/pending siguen siendo verde/rojo/gris/ámbar fijos
(
RESULT_COLORS,core/report/charts.ts/src/ui/colors.ts) — el branding se aplica solo al "empaque" (header, acentos, botones), nunca al semáforo de resultados. Verificado con un test dedicado (reportGenerator.test.ts: "nunca toca los colores semánticos..."). core/types/config.ts: nuevoBrandingConfigSchema(logoPath+ 4 colores hex, todos opcionales/nulleables, default = nada configurado), con validación de formato hex vía regex (ConfigValidationErrorsi un color no es#rgb/#rrggbb).core/types/report.ts:BrandingInput(lo que recibecreateReportGeneratordesde el caller, conlogoAbsolutePathya resuelto) vs.BrandingMeta(ya resuelto para el template: logo copiado, colores de contraste calculados,isBrandedexplícito).ProjectMetagana un campobranding: BrandingMetaobligatorio.core/report/reportGenerator.ts:pickReadableTextColor(hex)(exportada, con tests propios) elige#ffffff/#111111por contraste WCAG real (no asume que un fondo oscuro de marca siempre quiere texto blanco — un acento CLARO como el cian de este pedido real necesita texto oscuro para pasar WCAG AA, ver los tests). El logo se copia (best-effort, nunca hace fallargenerate()si el archivo no existe — mismo criterio quetryGenerateThumbnail) aoutputDir/assets/branding/logo{ext}.- Templates (
templates/default/): nuevo partialheader.hbs(block partial,{{#> header heading=...}}...{{/header}}) con DOS variantes segúnproject.branding.isBranded: la neutra de siempre (.qa-topbar, sin cambios) o una franja de marca a todo el ancho (.qa-brandbar, con logo) + una franja fina de 3 colores como "firma" visual (ver skillfrontend-design). El CSS de marca vive TODO dentro de{{#if project.branding.isBranded}}enstyles.hbs— sin branding configurado, no se emite ni una línea de ese bloque (el reporte desample-project/queda byte-a-byte equivalente a antes; verificado regenerándolo y confirmando 0 ocurrencias deqa-brandbar). --qa-linkcon!important(único caso en toda la hoja de estilos): el acento de marca debe ganarle a los 4 selectors de tema que ya fijan esa variable (:rootclaro/oscuro +[data-theme]+prefers-color-scheme); igualar/superar esa especificidad a mano sería frágil ante el próximo cambio de tema —!importantexpresa directamente la regla real ("el acento de marca configurado siempre gana"). Documentado in-line con la razón exacta.- Server:
ServerContextganabrandingLogoAbsolutePath(resuelto igual quefeaturesDir/evidenceBaseDir/etc., duplicado enadapters/cli/commands/run.tspor la regla de dependencia cli↔server). Nuevo routerroutes/branding.ts:GET /branding/logo(fuera de/api, mismo criterio que los prefijos estáticos de evidencia/reportes — sirve un archivo, no JSON) conres.sendFile, 404NO_BRANDING_LOGOsi no hay logo o el archivo no existe.GET /api/featuresganaprojectName(antes solo vivía dentro desession, no servía si todavía no había sesión) ybranding(colores +logoUrlya armado como/branding/logoonull). src/ui/:colors.tsgana su propia copia depickReadableTextColor(mismo criterio de duplicación deliberada queRESULT_COLORS).App.tsxaplica la paleta condocument.documentElement.style.setPropertysobre variables CSS (--accent/--accent-contrastse REPUNTAN directamente — es literalmente el rol de "acento principal" acordado con el usuario;--brand-primary/--brand-ctason variables nuevas con default neutro enstyles.css) — un estilo inline gana sobre cualquier regla de hoja de estilo en cualquier tema sin necesitar!important(a diferencia del reporte HTML estático, que sí lo necesita por ser una hoja de estilos, no JS). Header con logo condicional + franja de firma (mismo elemento visual que el reporte). Botón "Exportar como ZIP" usa la nueva clase.button--cta(--brand-cta, default =--accentsi no hayctaColorconfigurado).- Aplicado al proyecto real del usuario (
mi-proyecto-qa/, fuera de este repo): logo copiado ami-proyecto-qa/branding/logo.png,qa-config.jsoncon los 4 colores reales. Verificado contra el server real ya corriendo (reiniciado para tomar el nuevo build):GET /api/featuresdevuelve la paleta real,GET /branding/logosirve el archivo real, el reporte generado tiene el header de marca.sample-project/(el demo público del repo) queda sin tocar — sinbrandingen su config, se ve exactamente igual que antes.
El branding (arriba) resolvió colores/logo, pero el usuario reportó que el
reporte seguía viéndose "feo": layout genérico de panel de admin (tabla
HTML plana, donut flotando con espacio vacío alrededor, sin jerarquía).
Rediseño real (no solo paleta), aplicando la skill frontend-design.
- Verificación con capturas reales, no a ciegas: este entorno tiene
Chromium/Chrome instalados (
google-chrome --headless --screenshot=... --window-size=W,H archivo.html), aunque no haya una extensión de navegador conectada a la sesión de Claude. Se generaron reportes de prueba con datos mixtos (pass/fail/skip/pending, evidencia real, un defecto) y se los capturó en claro/oscuro/ancho angosto para iterar el diseño viendo el resultado real, no solo razonando sobre el CSS. Método recomendado para cualquier ajuste visual futuro del reporte. - Tipografía de dos familias (
--qa-font-display: pila serif del sistema —Iowan Old Style/Palatino/Georgia — sin fuentes externas ni embebidas, sigue siendo 100% offline;--qa-font-body: la sans de siempre). La serif se usa SOLO para números protagonistas (el % del hero, el % de cada feature) — le da al reporte un aire de "documento formal" en vez de "panel de admin", sin tocar la legibilidad del resto del texto. - El hero real: el
%de aprobación pasó a ser el elemento más grande y protagonista del dashboard (texto serif de ~4rem), con una etiqueta "eyebrow" (REPORTE DE EJECUCIÓN) que enmarca el documento. El donut bajó de 220px a 168px (renderDonutChart(summary, { size: 168, strokeWidth: 24 })enreportGenerator.ts) — pasa de ser el protagonista a un visual de acompañamiento, ya que el número en texto cumple ese rol ahora..qa-dashboard-gridse renombró a.qa-hero(rompe el selector que usaba el test de regresión de SVG responsivo de la fase anterior — se actualizó el test, no hay otro impacto). - El "riel" de resultado (firma visual elegida, ver skill
frontend-design "spend your boldness in one place"): una franja de 4px
del color semántico correspondiente en el borde izquierdo de cada fila
de feature (dashboard) y cada card de scenario (detalle) — se lee de
arriba a abajo sin necesitar texto, como un semáforo de inspección real.
Bug real encontrado y corregido durante esta fase:
.qa-railse había declarado ANTES de.qa-card/.qa-scenarioen la hoja de estilos; como esas clases fijanbordercon la forma corta (las 4 esquinas, incluyendoborder-left) y la especificidad es igual (una sola clase cada una), el orden en la hoja decidía y.qa-card/.qa-scenariopisaban en silencio el color del riel — exactamente el pitfall de especificidad que la skill frontend-design advierte explícitamente. Se corrigió declarando.qa-rail/.qa-rail--*al FINAL de la hoja de estilos (con el porqué documentado in-line), para que siempre gane sin depender de con qué otra clase se combine. - Lista de features sin
<table>: la tabla HTML plana (table.qa-feature-table, eliminada) se reemplazó por.qa-feature-list/.qa-feature-row— cada fila es un<a>completo (toda la fila es clickeable, no solo un link "Ver detalle" al final), con el riel de resultado, tags, conteo de steps, badge, el % en la tipografía serif, y un→como afordancia. - Numeración real de steps (CSS
counter-reset/counter-incrementen.qa-scenario/.qa-step, sin helper de Handlebars nuevo): los steps de un scenario corren en un orden real y secuencial — a diferencia de un marcador01/02/03puramente decorativo (que la skill frontend-design pide cuestionar), acá el número SÍ es información real ("falló el step 3"), así que se agregó. - Header con
.qa-eyebrowaplicado a AMBAS variantes (con y sin marca) — a diferencia del branding (que sí es 100% condicional), este rediseño de tipografía/estructura aplica también asample-project/y a cualquier proyecto sinbrandingconfigurado. El comentario departials/header.hbsque decía "sin marca, SIN NINGÚN cambio visual" se actualizó para reflejar esto — la afirmación de "sin cambios" ahora aplica solo al fondo/logo/franja de color, no al resto del header. - Verificado en claro, oscuro, con branding, sin branding (
sample-project/ymi-proyecto-qa/regenerados y capturados en ambos temas), y en un viewport angosto (900px) para confirmar que el hero sigue envolviendo bien. 165 tests en verde (1 actualizado por el rename de clase, ninguno nuevo necesario — el rediseño es puramente de presentación sobre datos ya cubiertos).
Reportado por un usuario real: adjuntó evidencia a varios steps, marcó
resultados correctamente, pero en el reporte final solo aparecía UNA
evidencia (la del último step tocado) — el resto de los archivos existían
físicamente en evidence/ pero no estaban referenciados en session.json.
Diagnóstico confirmado con datos reales (no especulado): se cruzaron
los mtime de los archivos físicos de evidencia con createdAt/updatedAt
de session.json. El createdAt de la sesión final era ~6 segundos
anterior a la única evidencia que sí aparecía — es decir, se había creado
una sesión NUEVA justo antes de esa subida, descartando silenciosamente
5 evidencias previas de la sesión anterior (que ya estaba en estado
'completed').
Causa raíz real: POST /api/session/select (routes/session.ts)
exigía ?force=true para descartar una sesión existente solo si
existing.status !== 'completed' — la justificación original (ver el
JSDoc de fase 5a, ahora corregido) era "una sesión completada no arriesga
perder nada". Esa premisa es FALSA: 'completed' es el estado normal de
una sesión justo ANTES de generar el reporte, y puede tener evidencia real
sin exportar. En este incidente puntual, un restart del server (hecho por
la sesión de Claude Code para levantar un build nuevo) interrumpió el
flujo del usuario; al volver a la pantalla de selección con la sesión ya
'completed', seleccionar de nuevo no pidió ninguna confirmación y
descartó el progreso.
Corrección: el chequeo pasó de basarse en status a basarse en
progreso real — sessionHasRecordedProgress(state) (nueva función en
routes/session.ts): true si CUALQUIER step tiene un resultado
distinto de 'pending', evidencia adjunta, o notas, sin importar
status. Un 'completed' alcanzado navegando con "Siguiente" sin marcar
nada (posible, ver Runner.tsx: navegar y marcar resultado son acciones
separadas) sigue sin pedir confirmación, porque ahí sí es cierto que no
hay nada que perder. Tests nuevos en app.test.ts cubren las 4
combinaciones (in_progress/completed × con/sin progreso real).
Nota operativa para el futuro (no es un cambio de código, es un recordatorio): reiniciar el server de un proyecto mientras el usuario puede estar interactuando con el runner en su navegador es una acción de mayor riesgo de lo que parece — no pierde datos por sí sola, pero puede llevar al usuario a un flujo (pantalla de selección sobre una sesión ya completada) que, antes de esta corrección, sí perdía datos. Preguntar o avisar antes de reiniciar un server real de un proyecto del usuario evita esta clase de interacción.
Los archivos de evidencia "huérfanos" de este incidente puntual
(mi-proyecto-qa/evidence/...) NO se borraron — siguen físicamente en
disco bajo las mismas rutas deterministas ({featureId}/{scenarioId}/{stepId}/),
así que son recuperables re-adjuntándolos a mano si el usuario los
necesita para su reporte.
Consecuencia directa e inmediata del fix de arriba, encontrada por el
usuario en la práctica apenas se aplicó: POST /api/session/select ahora
exige ?force=true para cualquier sesión con progreso real (sin importar
status) — correcto del lado del server, pero el frontend nunca pasaba
force=true para una sesión 'completed' (FeatureSelect.tsx,
handleStart, solo lo hacía para status !== 'completed'), y el banner de
sesión completada ("podés revisar su reporte") no tenía NINGÚN botón real
para hacerlo. Resultado: con una sesión completada con progreso, "Iniciar
ejecución" siempre daba 409 y no había ninguna otra acción disponible — el
QA quedaba completamente bloqueado, sin poder ni ver su sesión ni empezar
una nueva.
Corrección (dos partes):
FeatureSelect.tsx: el banner de sesión'completed'ahora tiene un botón real ("Ver sesión / generar reporte") que usa el mismoonContinueque ya existía para sesionesin_progress— entra al runner en vez de quedar como texto sin acción.handleStartunificó su lógica: confirma y pasaforce=truepara CUALQUIER sesión existente (hasExistingSession), no solo lasin_progress.SessionEngine.close()(método nuevo,core/session/sessionEngine.ts): borrasession.jsondel disco y limpia el estado en memoria, sin tocar evidencia ni reportes ya generados — permite quecreateSession()vuelva a llamarse sin quesessionHasRecordedProgresstenga nada que evaluar (no hay sesión). Expuesto comoPOST /api/session/close(routes/session.ts) y como botón "Cerrar sesión" en el panel de reporte del runner (Runner.tsx, después de "Generar reporte"/"Ver reporte"/"Exportar como ZIP" — pedido explícito del usuario). Pide confirmación solo si todavía no se generó el reporte de esa sesión (reportUrlsiguenullen el estado del componente); si ya se generó, cerrar es "prolijo" y no arriesga nada.- Tests nuevos en las 3 capas:
sessionEngine.test.ts(close(): borra archivo, limpia estado, no-op sin sesión, no-op llamado dos veces, permite recrear después),app.test.ts(POST /api/session/closereal vía HTTP, incluyendo que habilita unselectposterior sin 409),FeatureSelect.test.tsxyRunner.test.tsx(componentes: botón visible y funcional en ambos casos, confirmación respetada).
Lección para el propio proceso de esta sesión: un fix de backend que cambia una condición de negocio (acá, "cuándo se permite descartar una sesión") necesita auditarse contra TODOS los callers reales de ese endpoint, no solo contra los tests existentes — el frontend nunca se revisó al aplicar el fix original, y eso es lo que dejó al usuario bloqueado en producción (su propio proyecto) inmediatamente después.
Reportado por un usuario real: generó un reporte, volvió a la sesión, agregó evidencia y notas nuevas, volvió a generar — "Ver reporte" seguía mostrando la versión vieja, ni el texto ni la evidencia se actualizaban.
Diagnóstico, verificado antes de tocar código (no asumido): se
reprodujo el flujo completo contra un proyecto de prueba real, comparando
el CONTENIDO EN DISCO de reports/ antes/después de regenerar (sin
navegador de por medio). El archivo en disco SIEMPRE reflejaba la
información nueva correctamente — ReportGenerator no tenía ningún bug.
El problema era 100% del lado del navegador: GET /reports-static/...
(express.static(context.reportsDir), sin opciones) manda por default
Cache-Control: public, max-age=0 + ETag/Last-Modified — en teoría
exige revalidar, pero en la práctica el navegador del usuario seguía
sirviendo una copia cacheada de index.html/features/*.html (mismo
nombre de archivo en cada regeneración) sin ir a la red.
Corrección: app.ts monta EVIDENCE_STATIC_PREFIX y
REPORTS_STATIC_PREFIX con Cache-Control: no-store explícito
(etag/lastModified desactivados también, para no dejar ningún
mecanismo de revalidación que un navegador pueda interpretar distinto) —
la directiva más fuerte posible: nunca guardar nada de la respuesta. Se
aplicó a AMBOS prefijos (no solo reports-static) porque el mismo patrón
de "sobrescribir un archivo con el mismo nombre" aplica a la evidencia
mostrada en vivo en el runner (re-subir con el mismo nombre a un step).
El costo de deshabilitar cache por completo es irrelevante acá: una sola
persona, uso local, contenido que cambia todo el tiempo — no hay ningún
escenario real donde cachear esto ayude.
Test de regresión en app.test.ts: genera el reporte, verifica notas
"versión UNO", cambia el resultado con notas "versión DOS", regenera
sobre la MISMA URL, y confirma que el HTML servido tiene la versión nueva
Y el header Cache-Control: no-store — ejercita el bug reportado
exactamente como ocurrió (a nivel HTTP, no solo generación de archivos).
Post-fase 6 — bugfix real: evidencia "huérfana" reaparecía en el runner tras cerrar/re-crear una sesión
Reportado por el mismo usuario, inmediatamente después del fix de caché de arriba (que no resolvía esto — eran dos bugs distintos coincidiendo en el tiempo). Al cerrar una sesión y volver a seleccionar la MISMA feature, el runner mostraba evidencia "residual" en steps que la sesión nueva nunca había tocado; y al generar el reporte, ese residuo NO aparecía (el reporte mostraba menos evidencia de la que el runner sugería que había).
Diagnóstico (verificado leyendo el código real, tras descartar con un
repro aislado que la generación de reportes tuviera un bug — no lo tenía):
GET /api/session/step/:stepId/evidence (el endpoint que alimenta la
preview de evidencia en vivo del runner, EvidenceArea.tsx) devolvía
services.evidenceStore.list(stepId) tal cual, sin filtrar.
EvidenceStore.list() reconstruye evidencia ESCANEANDO EL FILESYSTEM (ver
su JSDoc en core/types/evidence.ts — diseño deliberado de fase 2, para
poder recuperar metadata si session.json se corrompiera). Como
SessionEngine.close() (ver el bugfix anterior) NUNCA borra los archivos
físicos, y los stepId son determinísticos (misma feature re-seleccionada
= mismos nombres de carpeta), una sesión nueva reutiliza exactamente las
mismas carpetas de evidencia que la sesión anterior — y este endpoint las
mostraba como si ya estuvieran adjuntas, aunque step.evidenceFileIds (la
sesión real) estuviera vacío.
core/report/reportGenerator.ts (buildEvidenceViews) YA filtraba
correctamente por step.evidenceFileIds desde que existe — este endpoint
de la API era el ÚNICO lugar con la inconsistencia, y es justo el que
determina qué ve el QA mientras ejecuta. Corrección: el mismo patrón de
filtrado (Map por id + filtrar por step.evidenceFileIds), para que el
runner muestre EXACTAMENTE lo que el reporte va a incluir. Test de
regresión: cerrar sesión con evidencia adjunta, re-seleccionar la misma
feature, confirmar que GET .../evidence del mismo stepId devuelve
[] a pesar de que el archivo físico sigue en disco.
No resuelto a propósito (fuera de alcance de este fix, documentado):
los archivos huérfanos siguen ocupando espacio en evidence/ — close()
deliberadamente no los borra (ver su JSDoc). Con este fix ya no se
MUESTRAN ni se INCLUYEN en ningún reporte, que era el problema real
reportado; limpiar el disco es un problema distinto (cosmético/espacio),
no de corrección funcional.
Pedido real de un usuario que corre varios proyectos de QA a la vez en la
misma máquina (cada uno con su propio qa-config.json): antes, si
config.server.port ya estaba ocupado (típico con el default 3000
repetido entre proyectos), run fallaba con un EADDRINUSE crudo de Node
— la única salida era editar qa-config.json a mano para asignarle otro
puerto a cada proyecto, cada vez.
startServer (adapters/server/index.ts) ahora prueba port, port + 1,
port + 2, ... hasta MAX_PORT_ATTEMPTS (20) antes de rendirse con un
QaError (PORT_UNAVAILABLE) — mismo criterio que Vite/Storybook/etc.
config.server.port queda como el puerto de ARRANQUE, no como el puerto
real garantizado; startServer sigue devolviendo (y logueando, y abriendo
en el navegador vía open()) la url REAL con el puerto que efectivamente
consiguió, así que el QA nunca necesita saber ni fijar el puerto a mano
para poder correr dos proyectos en paralelo. Tests en
adapters/server/index.test.ts (puerto libre sin cambios de
comportamiento, un puerto ocupado, varios consecutivos ocupados, y los 20
agotados → PORT_UNAVAILABLE).
Mismo usuario, mismo pedido de fondo ("que cualquier persona tenga que
editar/escribir lo menos posible para correr una sesión"): hoy run se
invoca siempre desde una terminal (qa-evidence-reporter run), lo cual es
fricción real para un QA que no vive en la terminal. Se evaluó y se
descartó a propósito una integración nativa de "clic derecho sobre un
.feature → Run" al estilo Serenity: eso depende de un test-runner de IDE
(convención del ecosistema Java, sin equivalente estándar en un CLI de
Node) y requeriría, según el SO/editor, o bien una extensión de VS Code
propia (a publicar y mantener, solo cubre VS Code) o bien integración a
nivel de sistema operativo (registro de Windows, Quick Action de macOS,
acciones de Nautilus en Linux — tres implementaciones frágiles y difíciles
de instalar/desinstalar limpiamente). Ninguna de las dos encaja con el
criterio de "cero config extra" que ya sigue el resto del proyecto (ver,
por ejemplo, la decisión de fase 1 sobre thumbnails de video).
En su lugar, init (adapters/cli/commands/init.ts) ahora deja en la raíz
del proyecto QA tres scripts ejecutables con el mismo comando
(qa-evidence-reporter run, tras cd a su propio directorio): run.sh y
run.bat, más run.command con el mismo contenido que run.sh — Finder
en macOS solo ejecuta con doble clic los archivos .command (a un .sh
lo abre en un editor de texto en vez de correrlo). Se generan los tres
siempre, sin importar el SO donde corrió init, porque un proyecto de QA
suele ser un repo compartido entre un equipo con máquinas distintas.
run.sh/run.command quedan con permiso de ejecución (chmod 0o755)
explícito — sin eso, un doble clic en Linux/macOS tampoco los ejecuta.
printNextSteps menciona la opción de doble clic junto al comando de
terminal, sin reemplazarlo (la terminal sigue funcionando igual que
siempre). Tests en init.test.ts (contenido de los tres archivos + modo
de archivo 0o755 de run.sh/run.command).
Reportado por el mismo usuario, probando el evolutivo anterior en Ubuntu:
doble clic en run.sh abrió un editor de texto (Bloc de notas/gedit) en
vez de correr el script, a pesar del chmod 0o755 ya aplicado.
Causa real: el bit de ejecución es NECESARIO pero no SUFICIENTE.
Nautilus/GNOME Files (default en Ubuntu y la mayoría de distros modernas)
trata los "archivos de texto ejecutables" como texto por preferencia
propia del gestor de archivos — el doble clic los abre para editar, no los
corre, sin importar los permisos del archivo. La suposición original
("en Linux la convención es .sh", fase anterior) confundía la convención
de invocación por TERMINAL (./run.sh, donde el bit de ejecución sí es lo
único que importa) con el comportamiento de doble clic desde un gestor de
archivos gráfico, que son dos cosas distintas.
Corrección: se agregó run.desktop, el mecanismo real de "acceso
directo ejecutable" en Linux (especificación Desktop Entry de
freedesktop.org, reconocida por GNOME/KDE/XFCE/Cinnamon/MATE) —
Exec=bash "<ruta absoluta a run.sh>", Terminal=true (para que un error
de arranque quede visible, mismo criterio que el pause de run.bat).
La ruta a run.sh va ABSOLUTA (resolve(runScriptShPath), resuelta en el
momento de init, cuando cwd es conocido) en vez de usar campos de
sustitución del formato (%k, etc.): esos campos están deprecados o se
soportan de forma inconsistente entre gestores de archivos, y la ruta
absoluta no tiene ese problema porque no depende de desde dónde se lanzó
el .desktop. run.desktop también necesita el bit de ejecución
(chmod 0o755) para que Nautilus lo reconozca como aplicación lanzable en
vez de, otra vez, como texto plano — mismo requisito, ahora aplicado
correctamente al archivo que sí lo necesita.
Se documentó explícitamente en printNextSteps (init.ts) y en el README
que en Linux corresponde usar run.desktop, no run.sh — run.sh se
conserva (referenciado por run.desktop y por run.command, y sigue
sirviendo para quien prefiera ./run.sh desde una terminal), pero deja de
promocionarse como el lanzador de doble clic recomendado en Linux. Test
nuevo en init.test.ts (contenido de run.desktop, ruta absoluta correcta
a run.sh, y modo de archivo 0o755).
Post-fase 6 — reversión: se eliminan los lanzadores de doble clic (run.sh/run.command/run.bat/run.desktop)
Mismo usuario: tras el fix anterior (run.desktop), el doble clic en
Ubuntu SEGUÍA abriendo un editor de texto en vez de ejecutar nada — ni
run.sh/run.command ni run.desktop funcionaron en la máquina real de
prueba. No hay forma de diagnosticar la causa exacta desde este entorno de
desarrollo (sin un gestor de archivos gráfico real para reproducir):
podría ser una variante de gestor de archivos distinta a Nautilus, un flag
de confianza/"Allow Launching" que Nautilus pide antes del primer doble
clic y no se cubrió, u otra particularidad del entorno de escritorio de esa
máquina en particular. Dos intentos con hipótesis razonables (bit de
ejecución, luego .desktop) fallaron igual en la prueba real; seguir
adivinando un tercer mecanismo sin poder reproducir el problema no es un
uso razonable de esfuerzo — se revierte todo lo agregado en las dos
entradas anteriores en vez de intentar un cuarto enfoque a ciegas.
Se eliminaron de init.ts: la generación de run.sh/run.command/
run.bat/run.desktop (contenido, chmod, y las líneas de
printNextSteps que los mencionaban), y sus tests en init.test.ts. El
flujo vuelve a ser el original: init seguido de qa-evidence-reporter run (o el run.bat de Windows sigue sin reportarse roto — no se descartó
por falta de confirmación, pero se quitó junto con el resto para no dejar
una superficie a medias entre "confirmado" y "no probado"). El fallback
automático de puerto (entrada de más arriba, confirmado funcionando por
este mismo usuario) NO se tocó — son dos features independientes que
llegaron en el mismo pedido, y solo la de los lanzadores resultó
irreproducible/fallida en uso real.
Post-fase 6 — cambio de política: el branding de Quind pasa a ser el default de TODO proyecto nuevo (revierte una decisión previa)
Pedido explícito del usuario: quiere que todos los proyectos de QA se vean iguales, y que el branding sea un insumo mínimo (no algo opcional que cada quien configure a mano) — es decir, deja de ser "branding por cliente, opt-in" (el diseño original, ver la entrada de branding más arriba en este documento) y pasa a ser "identidad fija de Quind, por defecto, en todo lo que genere esta herramienta".
Esto revierte, a propósito, una decisión previa documentada con su propia
razón: el .gitignore original excluía /branding/ explícitamente
porque "este repo es la herramienta genérica, no debe llevar el branding de
ningún cliente/empresa en particular (y menos en un repo público)" (ver
commit 51fb773). Antes de implementar este cambio se confirmó
explícitamente con el usuario que este repo/paquete es de uso exclusivo
interno de Quind (no se distribuye ni se instala fuera de Quind) — con
esa confirmación, la razón original para no grabar una marca fija en el
código fuente deja de aplicar, y grabar el estándar de Quind como default
del propio código pasa a ser correcto en vez de un error de diseño. Si esa
premisa cambiara en el futuro (el paquete pasa a distribuirse fuera de
Quind), esta decisión debe revisarse desde cero — no alcanza con volver a
hacer branding opcional a nivel de esquema (eso ya lo es, ver siguiente
punto).
Implementación:
branding/logo.png(raíz del repo, antes solo un archivo de staging local ignorado por git) ahora SÍ se versiona, y se agrega"branding"a"files"depackage.jsonpara que se publique junto condist/ytemplates/.src/adapters/cli/defaultBrandingPaths.ts(nuevo):DEFAULT_BRANDING(los 4 colores hex ya usados en el proyecto real del usuario,mi-proyecto-qa/— primaryColor#1e3543, accentColor#00c4e9, highlightColor#ffb91c, ctaColor#ff5530) yDEFAULT_BRANDING_LOGO_PATH, resuelto con la misma técnica queDEFAULT_TEMPLATE_DIR(templatePaths.ts:import.meta.url+ 3 niveles hacia arriba, nuncaprocess.cwd()— mismo razonamiento, mismo archivo publicado junto adist/).adapters/cli/commands/init.ts(runInit): copia ese logo a<proyecto>/branding/logo.pngy escribe el bloquebrandingcompleto enqa-config.json(antes,initno escribía la clavebrandingen absoluto). El esquema (core/types/config.ts,BrandingConfigSchema) NO cambió — sigue aceptandobrandingausente/nullcomo válido, con el mismo default neutro de siempre. Lo que cambió es una decisión de este comando de CLI puntual, no del core: alguien que arme unqa-config.jsona mano (sin pasar porinit) o que edite uno generado para sacar el branding sigue pudiendo hacerlo sin ningún error de validación.sample-project/(retrofit explícitamente pedido por el usuario, no automático): ganasample-project/branding/logo.png(copia del logo estándar) y el mismo bloquebrandingen suqa-config.json. Esto contradice el comentario original de la entrada de branding más arriba en este documento ("sample-project/... queda sin tocar ... se ve exactamente igual que antes") — se deja esa entrada vieja intacta (era correcta en el momento en que se escribió) y se documenta el cambio acá, siguiendo el mismo criterio de todo este archivo: no reescribir el historial, agregar la entrada nueva que lo supera.~/mi-proyecto-qa(el proyecto duplicado sin branding, ver la entrada de arriba sobre el bug de doble clic) NO se tocó — el usuario, al elegir el alcance de este retrofit, pidió explícitamente solosample-project/.- Verificado de punta a punta contra un server real (copia de
sample-project/en un directorio temporal, sin tocar el repo):GET /api/featuresdevuelve la paleta real (primaryColor/accentColor/highlightColor/ctaColor+logoUrl: "/branding/logo"),GET /branding/logoresponde200con el PNG real (1920×1081). Test nuevo eninit.test.ts(elqa-config.jsongenerado incluyebrandingcon los 4 colores ylogoPath, y el archivo copiado es byte a byte idéntico al logo fuente del paquete).
Reportado por el usuario: "Ctrl+C + Ctrl+V" (la feature de pegar evidencia desde el portapapeles, ver ARCHITECTURE.md "UX del runner") solo dejaba la ÚLTIMA imagen pegada por step — pegar una segunda reemplazaba a la primera en vez de agregarse.
Causa real, ya documentada como limitación aceptada desde fase 2 (ver
JSDoc viejo de EvidenceFile.id en core/types/evidence.ts, y el
createEvidenceStore de core/evidence/evidenceStore.ts): el id de una
evidencia es un hash de featureId:scenarioId:stepId:originalFilename —
subir dos archivos con el mismo originalFilename para el mismo step
produce el mismo id, y por lo tanto la misma ruta en disco (save()
escribía directo con writeFile(filePath, buffer), pisando lo que hubiera
ahí). El navegador le pone el mismo nombre genérico a TODAS las imágenes
pegadas del portapapeles (típicamente image.png, no algo que este código
controle) — por eso el bug aparece siempre con paste y prácticamente nunca
con file picker/drag&drop (donde los nombres reales casi siempre difieren).
El JSDoc de fase 2 ya marcaba esto como "deduplicar con sufijos (-2, -3,
...) queda para una fase futura si se necesita" — esa fase es esta.
Corrección: nueva función resolveNonCollidingFilename(stepDir, filename) en evidenceStore.ts, llamada al inicio de save() ANTES de
calcular el id. Si stepDir/filename ya existe, prueba "nombre (1).ext",
"nombre (2).ext", ... (mismo criterio que "guardar copia" de cualquier
explorador de archivos) hasta encontrar un nombre libre — reutiliza
fileExists (ya existía, usado por scanEvidenceTree), sin agregar
ninguna dependencia nueva. El nombre deduplicado pasa a ser el
originalFilename real devuelto (y por lo tanto el que entra al hash del
id) — se preserva la propiedad de fase 2 de "todo se puede recalcular
escaneando el filesystem, sin índice separado en memoria/disco": no hay
ninguna metadata sobre la deduplicación que no esté ya en el nombre físico
del archivo.
No es a prueba de carreras (dos requests HTTP genuinamente concurrentes
podrían ver ambas "no existe" antes de que cualquiera escriba — clásico
TOCTOU): se aceptó el mismo riesgo residual que ya tolera el resto de este
proyecto para su caso de uso real (un solo QA, acciones humanas de paste
con espaciado real entre sí, ver session.json "sin bloqueo de archivo" en
la sección de arriba) — dentro de una misma request HTTP, el route handler
(adapters/server/routes/session.ts) ya guarda el lote de archivos con un
for...of secuencial (no Promise.all), así que pegar varias imágenes de
una sola vez tampoco corre ese riesgo.
Tests actualizados en evidenceStore.test.ts: el test viejo
("guardar el mismo input dos veces produce el mismo id") probaba
literalmente el comportamiento que se acaba de eliminar a propósito — se
reemplazó por tres tests: determinismo del id via el contenido (dos
baseDir distintos, mismo featureId/scenarioId/stepId/originalFilename,
contenido distinto → mismo id), la deduplicación real (dos saves con
"image.png" y contenido distinto en el mismo step → "image.png" +
"image (1).png", ambos archivos en disco con su contenido real e ids
distintos, una tercera colisión sigue a " (2)"), y que un
originalFilename ya único no se toca (sin cambio de comportamiento para
el caso normal). Verificado además de punta a punta contra un server real
(dos POST .../evidence con el mismo filename=image.png y contenido
distinto, simulando exactamente dos pastes de portapapeles): la respuesta
del segundo devuelve "image (1).png", y ambos archivos quedan en disco
sin pisarse.