robotframework-evidence-reporter 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Angel Gerardo Molina Valdez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,137 @@
1
+ Metadata-Version: 2.4
2
+ Name: robotframework-evidence-reporter
3
+ Version: 0.1.0
4
+ Summary: Standalone business evidence reports per Robot Framework test
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Keywords: robotframework,selenium,evidence,reporting,pabot
8
+ Author: Angel Gerardo Molina Valdez
9
+ Requires-Python: >=3.12,<4.0
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Programming Language :: Python :: 3.15
15
+ Requires-Dist: jinja2 (>=3.1.6,<4.0)
16
+ Requires-Dist: pillow (>=11.0,<14.0)
17
+ Requires-Dist: robotframework (>=7.0,<8.0)
18
+ Project-URL: Homepage, https://angel-valdezzz.github.io/robotframework-evidence-reporter/
19
+ Project-URL: Repository, https://github.com/angel-valdezzz/robotframework-evidence-reporter
20
+ Description-Content-Type: text/markdown
21
+
22
+ # Robot Framework Evidence Reporter
23
+
24
+ Un HTML autocontenido por caso de Robot Framework, orientado a evidencias de negocio. Registra capturas explícitas, mensajes y metadatos durante la ejecución; genera el HTML después, desde CLI o Python.
25
+
26
+ [Documentación](https://angel-valdezzz.github.io/robotframework-evidence-reporter/) · [Documentación de keywords](https://angel-valdezzz.github.io/robotframework-evidence-reporter/reference/keywords.html) · [Ver reporte HTML](https://angel-valdezzz.github.io/robotframework-evidence-reporter/demo/passed.html)
27
+
28
+ ## Estado de publicación
29
+
30
+ El código, la documentación y la demo HTML están publicados. La publicación en PyPI se ejecuta mediante GitHub Actions y requiere el Trusted Publisher configurado en la cuenta del propietario. Por ahora se puede instalar desde una revisión Git fija o construir el wheel desde el repositorio.
31
+
32
+ ## Instalación
33
+
34
+ ```bash
35
+ # Disponible después de la primera publicación en PyPI
36
+ pip install robotframework-evidence-reporter
37
+ poetry add robotframework-evidence-reporter
38
+
39
+ # Instalación desde el código publicado mientras tanto
40
+ pip install "git+https://github.com/angel-valdezzz/robotframework-evidence-reporter.git@main"
41
+ ```
42
+
43
+ Python 3.12 o superior y Robot Framework 7. Para capturar páginas o elementos instala también SeleniumLibrary. La librería reutiliza su navegador activo: no abre otra sesión ni depende de un navegador concreto.
44
+
45
+ ## Uso en Robot
46
+
47
+ ```robotframework
48
+ *** Settings ***
49
+ Library SeleniumLibrary
50
+ Library EvidenceReporter
51
+
52
+ *** Test Cases ***
53
+ Registrar Cliente
54
+ Open Browser https://example.test chrome
55
+ Set Report Metadata Aplicación=Portal Ambiente=QA Ticket=QA-123
56
+ Capture Page Evidence Formulario de alta
57
+ ${milestone}= Create Milestone Cliente registrado
58
+ Capture Element Evidence css:.confirmation Confirmación milestone_id=${milestone}
59
+ Add Evidence Message El alta fue confirmada. milestone_id=${milestone}
60
+ [Teardown] Close All Browsers
61
+ ```
62
+
63
+ Los hitos son opcionales. Sin `milestone_id`, la evidencia se añade directamente al caso. No se registran automáticamente argumentos ni todos los logs técnicos; añade los mensajes de negocio que necesites.
64
+
65
+ ```bash
66
+ robot --outputdir results tests
67
+ rf-evidence build results/evidence --output reports
68
+ ```
69
+
70
+ Cada caso conserva su estado final de Robot (`PASS`, `FAIL`, `SKIP`), inicio, fin y duración, incluidos fallos del teardown. Un proceso interrumpido antes de guardar el resultado final conserva `INCOMPLETE`. Las fechas incluyen el offset horario del proceso que las registra.
71
+
72
+ ## Capturas y errores
73
+
74
+ | Keyword | Captura |
75
+ | --- | --- |
76
+ | `Capture Page Evidence` | Página visible del navegador activo |
77
+ | `Capture Element Evidence` | Elemento usando un locator SeleniumLibrary |
78
+ | `Capture Desktop Evidence` | Escritorio de la máquina donde corre Robot |
79
+ | `Attach Image Evidence` | Copia una imagen PNG, JPEG o WEBP existente |
80
+
81
+ Las capturas fallidas producen **WARN por defecto**, se registran en el HTML y no cambian el estado del caso. Usa `strict=${True}` por keyword o `strict=${True}` al importar la librería para exigir una captura. `strict=${False}` por keyword permite sobrescribir esa configuración global.
82
+
83
+ El usuario elige cuándo usar el escritorio. No hay detección automática de headless, remoto ni paralelismo. Pillow ImageGrab necesita un entorno gráfico compatible; en Linux puede requerir un backend de captura, y en macOS permisos de pantalla. La captura corresponde al escritorio del proceso, no necesariamente al navegador remoto. No se implementa página completa en esta versión.
84
+
85
+ Errores definidos: `BROWSER_UNAVAILABLE`, `ELEMENT_NOT_FOUND`, `SCREENSHOT_FAILED`, `DESKTOP_UNAVAILABLE`, `IMAGE_UNAVAILABLE`, `INVALID_IMAGE`, `STORAGE_ERROR`. Argumentos incorrectos, hitos ajenos al caso y errores internos no se silencian. Si falla el almacenamiento inicial o final del listener, Robot registra el error del listener; comprueba también que se hayan generado los JSON esperados.
86
+
87
+ ## Python y paralelo
88
+
89
+ ```python
90
+ from rf_evidence_reporter import build_reports
91
+
92
+ paths = build_reports("results/evidence", "reports")
93
+ ```
94
+
95
+ Cada caso usa un UUID y un directorio propio. Para Pabot proporciona un directorio compartido de esta ejecución, por ejemplo `output_dir=${EXECDIR}/results/evidence`. Genera los HTML después de que terminen todos los procesos. Usa un directorio nuevo por ejecución: el generador incluye todos los `case.json` encontrados y conserva cada intento separado.
96
+
97
+ ## Desarrollo
98
+
99
+ ```bash
100
+ poetry install
101
+ poetry run ruff check src tests scripts
102
+ poetry run ruff format --check src tests scripts
103
+ poetry run robocop check tests examples
104
+ poetry run python -m unittest discover -s tests -v
105
+ poetry run python scripts/validate.py
106
+ poetry run python scripts/demo.py
107
+ poetry run python -m robot.libdoc --name "Evidence Reporter" EvidenceReporter docs/reference/keywords.html
108
+ poetry run mkdocs build --strict
109
+ poetry build
110
+ ```
111
+
112
+ `scripts/validate.py` verifica Robot y Pabot, incluyendo un caso con fallo deliberado en teardown. Las pruebas del núcleo usan unittest de Python; las pruebas de integración usan Robot. La demo pública utiliza datos e imágenes ficticios, indicados en el propio reporte.
113
+
114
+ El HTML incluye imágenes, CSS y JavaScript sin recursos externos. Su visualizador funciona sin conexión y es navegable con teclado. Jira puede permitir adjuntarlo sin ofrecer vista previa; se puede descargar y abrir en un navegador. El contenido y las imágenes no se censuran automáticamente: registra solo datos apropiados para el destino del reporte.
115
+
116
+ ## Publicación
117
+
118
+ GitHub Actions valida los cambios. `main` publica MkDocs y la demo en GitHub Pages. Un cambio de `pyproject.toml` en `main`, o una release `vX.Y.Z` cuya versión coincida con el paquete, publica wheel y sdist en PyPI mediante Trusted Publishing. Consulta `docs/publishing.md` para el registro inicial del publisher.
119
+
120
+ Licencia MIT.
121
+
122
+ ## Estatus de evidencia y presentación
123
+
124
+ Las cuatro keywords de captura aceptan `status=INFO` (por defecto), `PASS`, `WARN` o `FAIL`. No aceptan `SKIP`; un valor inválido produce `INVALID_CAPTURE_STATUS`. El estatus describe la evidencia y no modifica el resultado de Robot. Si la captura falla, se registra una advertencia independientemente del estatus solicitado.
125
+
126
+ Consulta los [ejemplos completos con estatus de evidencia](https://angel-valdezzz.github.io/robotframework-evidence-reporter/guide/#estatus-de-evidencia-y-presentacion).
127
+
128
+ El HTML tiene pestañas **Resumen**, **Pasos** y **Logs**. Resumen muestra una sola insignia de estatus de ejecución (`SKIP` en amarillo). Pasos presenta capturas e hitos; sus logs son plegables. Logs agrupa mensajes y advertencias en bloques plegables, con bordes por nivel y fondo neutro. El switch permite elegir modo claro u oscuro; conserva la preferencia cuando el navegador permite almacenamiento local.
129
+
130
+ Las fechas visibles incluyen día/mes/año y hora con segundos. La zona horaria se indica al pie y el JSON conserva el timestamp completo. Los registros anteriores sin estatus de captura se presentan como `INFO`. No hay botón de impresión; PDF y Word quedan para una etapa posterior.
131
+
132
+ ## Navegación del reporte
133
+
134
+ En Pasos, las evidencias directas empiezan plegadas y tienen pestañas Evidencias/Logs del bloque. Los hitos empiezan abiertos; puedes contraerlos individualmente o usar Expandir/Contraer todos los hitos. Los controles globales no modifican el bloque directo.
135
+
136
+ La documentación comparte logo, colores y temas con el reporte. Consulta [Primera evidencia](https://angel-valdezzz.github.io/robotframework-evidence-reporter/getting-started/) para ejemplos de instalación y ejecución.
137
+
@@ -0,0 +1,115 @@
1
+ # Robot Framework Evidence Reporter
2
+
3
+ Un HTML autocontenido por caso de Robot Framework, orientado a evidencias de negocio. Registra capturas explícitas, mensajes y metadatos durante la ejecución; genera el HTML después, desde CLI o Python.
4
+
5
+ [Documentación](https://angel-valdezzz.github.io/robotframework-evidence-reporter/) · [Documentación de keywords](https://angel-valdezzz.github.io/robotframework-evidence-reporter/reference/keywords.html) · [Ver reporte HTML](https://angel-valdezzz.github.io/robotframework-evidence-reporter/demo/passed.html)
6
+
7
+ ## Estado de publicación
8
+
9
+ El código, la documentación y la demo HTML están publicados. La publicación en PyPI se ejecuta mediante GitHub Actions y requiere el Trusted Publisher configurado en la cuenta del propietario. Por ahora se puede instalar desde una revisión Git fija o construir el wheel desde el repositorio.
10
+
11
+ ## Instalación
12
+
13
+ ```bash
14
+ # Disponible después de la primera publicación en PyPI
15
+ pip install robotframework-evidence-reporter
16
+ poetry add robotframework-evidence-reporter
17
+
18
+ # Instalación desde el código publicado mientras tanto
19
+ pip install "git+https://github.com/angel-valdezzz/robotframework-evidence-reporter.git@main"
20
+ ```
21
+
22
+ Python 3.12 o superior y Robot Framework 7. Para capturar páginas o elementos instala también SeleniumLibrary. La librería reutiliza su navegador activo: no abre otra sesión ni depende de un navegador concreto.
23
+
24
+ ## Uso en Robot
25
+
26
+ ```robotframework
27
+ *** Settings ***
28
+ Library SeleniumLibrary
29
+ Library EvidenceReporter
30
+
31
+ *** Test Cases ***
32
+ Registrar Cliente
33
+ Open Browser https://example.test chrome
34
+ Set Report Metadata Aplicación=Portal Ambiente=QA Ticket=QA-123
35
+ Capture Page Evidence Formulario de alta
36
+ ${milestone}= Create Milestone Cliente registrado
37
+ Capture Element Evidence css:.confirmation Confirmación milestone_id=${milestone}
38
+ Add Evidence Message El alta fue confirmada. milestone_id=${milestone}
39
+ [Teardown] Close All Browsers
40
+ ```
41
+
42
+ Los hitos son opcionales. Sin `milestone_id`, la evidencia se añade directamente al caso. No se registran automáticamente argumentos ni todos los logs técnicos; añade los mensajes de negocio que necesites.
43
+
44
+ ```bash
45
+ robot --outputdir results tests
46
+ rf-evidence build results/evidence --output reports
47
+ ```
48
+
49
+ Cada caso conserva su estado final de Robot (`PASS`, `FAIL`, `SKIP`), inicio, fin y duración, incluidos fallos del teardown. Un proceso interrumpido antes de guardar el resultado final conserva `INCOMPLETE`. Las fechas incluyen el offset horario del proceso que las registra.
50
+
51
+ ## Capturas y errores
52
+
53
+ | Keyword | Captura |
54
+ | --- | --- |
55
+ | `Capture Page Evidence` | Página visible del navegador activo |
56
+ | `Capture Element Evidence` | Elemento usando un locator SeleniumLibrary |
57
+ | `Capture Desktop Evidence` | Escritorio de la máquina donde corre Robot |
58
+ | `Attach Image Evidence` | Copia una imagen PNG, JPEG o WEBP existente |
59
+
60
+ Las capturas fallidas producen **WARN por defecto**, se registran en el HTML y no cambian el estado del caso. Usa `strict=${True}` por keyword o `strict=${True}` al importar la librería para exigir una captura. `strict=${False}` por keyword permite sobrescribir esa configuración global.
61
+
62
+ El usuario elige cuándo usar el escritorio. No hay detección automática de headless, remoto ni paralelismo. Pillow ImageGrab necesita un entorno gráfico compatible; en Linux puede requerir un backend de captura, y en macOS permisos de pantalla. La captura corresponde al escritorio del proceso, no necesariamente al navegador remoto. No se implementa página completa en esta versión.
63
+
64
+ Errores definidos: `BROWSER_UNAVAILABLE`, `ELEMENT_NOT_FOUND`, `SCREENSHOT_FAILED`, `DESKTOP_UNAVAILABLE`, `IMAGE_UNAVAILABLE`, `INVALID_IMAGE`, `STORAGE_ERROR`. Argumentos incorrectos, hitos ajenos al caso y errores internos no se silencian. Si falla el almacenamiento inicial o final del listener, Robot registra el error del listener; comprueba también que se hayan generado los JSON esperados.
65
+
66
+ ## Python y paralelo
67
+
68
+ ```python
69
+ from rf_evidence_reporter import build_reports
70
+
71
+ paths = build_reports("results/evidence", "reports")
72
+ ```
73
+
74
+ Cada caso usa un UUID y un directorio propio. Para Pabot proporciona un directorio compartido de esta ejecución, por ejemplo `output_dir=${EXECDIR}/results/evidence`. Genera los HTML después de que terminen todos los procesos. Usa un directorio nuevo por ejecución: el generador incluye todos los `case.json` encontrados y conserva cada intento separado.
75
+
76
+ ## Desarrollo
77
+
78
+ ```bash
79
+ poetry install
80
+ poetry run ruff check src tests scripts
81
+ poetry run ruff format --check src tests scripts
82
+ poetry run robocop check tests examples
83
+ poetry run python -m unittest discover -s tests -v
84
+ poetry run python scripts/validate.py
85
+ poetry run python scripts/demo.py
86
+ poetry run python -m robot.libdoc --name "Evidence Reporter" EvidenceReporter docs/reference/keywords.html
87
+ poetry run mkdocs build --strict
88
+ poetry build
89
+ ```
90
+
91
+ `scripts/validate.py` verifica Robot y Pabot, incluyendo un caso con fallo deliberado en teardown. Las pruebas del núcleo usan unittest de Python; las pruebas de integración usan Robot. La demo pública utiliza datos e imágenes ficticios, indicados en el propio reporte.
92
+
93
+ El HTML incluye imágenes, CSS y JavaScript sin recursos externos. Su visualizador funciona sin conexión y es navegable con teclado. Jira puede permitir adjuntarlo sin ofrecer vista previa; se puede descargar y abrir en un navegador. El contenido y las imágenes no se censuran automáticamente: registra solo datos apropiados para el destino del reporte.
94
+
95
+ ## Publicación
96
+
97
+ GitHub Actions valida los cambios. `main` publica MkDocs y la demo en GitHub Pages. Un cambio de `pyproject.toml` en `main`, o una release `vX.Y.Z` cuya versión coincida con el paquete, publica wheel y sdist en PyPI mediante Trusted Publishing. Consulta `docs/publishing.md` para el registro inicial del publisher.
98
+
99
+ Licencia MIT.
100
+
101
+ ## Estatus de evidencia y presentación
102
+
103
+ Las cuatro keywords de captura aceptan `status=INFO` (por defecto), `PASS`, `WARN` o `FAIL`. No aceptan `SKIP`; un valor inválido produce `INVALID_CAPTURE_STATUS`. El estatus describe la evidencia y no modifica el resultado de Robot. Si la captura falla, se registra una advertencia independientemente del estatus solicitado.
104
+
105
+ Consulta los [ejemplos completos con estatus de evidencia](https://angel-valdezzz.github.io/robotframework-evidence-reporter/guide/#estatus-de-evidencia-y-presentacion).
106
+
107
+ El HTML tiene pestañas **Resumen**, **Pasos** y **Logs**. Resumen muestra una sola insignia de estatus de ejecución (`SKIP` en amarillo). Pasos presenta capturas e hitos; sus logs son plegables. Logs agrupa mensajes y advertencias en bloques plegables, con bordes por nivel y fondo neutro. El switch permite elegir modo claro u oscuro; conserva la preferencia cuando el navegador permite almacenamiento local.
108
+
109
+ Las fechas visibles incluyen día/mes/año y hora con segundos. La zona horaria se indica al pie y el JSON conserva el timestamp completo. Los registros anteriores sin estatus de captura se presentan como `INFO`. No hay botón de impresión; PDF y Word quedan para una etapa posterior.
110
+
111
+ ## Navegación del reporte
112
+
113
+ En Pasos, las evidencias directas empiezan plegadas y tienen pestañas Evidencias/Logs del bloque. Los hitos empiezan abiertos; puedes contraerlos individualmente o usar Expandir/Contraer todos los hitos. Los controles globales no modifican el bloque directo.
114
+
115
+ La documentación comparte logo, colores y temas con el reporte. Consulta [Primera evidencia](https://angel-valdezzz.github.io/robotframework-evidence-reporter/getting-started/) para ejemplos de instalación y ejecución.
@@ -0,0 +1,41 @@
1
+ [project]
2
+ name = "robotframework-evidence-reporter"
3
+ version = "0.1.0"
4
+ description = "Standalone business evidence reports per Robot Framework test"
5
+ authors = [{name = "Angel Gerardo Molina Valdez"}]
6
+ readme = "README.md"
7
+ requires-python = ">=3.12,<4.0"
8
+ license = "MIT"
9
+ keywords = ["robotframework", "selenium", "evidence", "reporting", "pabot"]
10
+ dependencies = ["robotframework>=7.0,<8.0", "jinja2>=3.1.6,<4.0", "pillow>=11.0,<14.0"]
11
+
12
+ [project.urls]
13
+ Homepage = "https://angel-valdezzz.github.io/robotframework-evidence-reporter/"
14
+ Repository = "https://github.com/angel-valdezzz/robotframework-evidence-reporter"
15
+
16
+ [project.scripts]
17
+ rf-evidence = "rf_evidence_reporter.cli:main"
18
+
19
+ [tool.poetry]
20
+ packages = [
21
+ {include = "rf_evidence_reporter", from = "src"},
22
+ {include = "EvidenceReporter", from = "src"},
23
+ ]
24
+ include = ["src/rf_evidence_reporter/templates/*.html"]
25
+
26
+ [tool.poetry.group.dev.dependencies]
27
+ robotframework-seleniumlibrary = "^6.9.0"
28
+ robotframework-pabot = "^5.1.0"
29
+ robotframework-robocop = "^9.1.0"
30
+ ruff = "^0.15.0"
31
+ mkdocs-material = "^9.7.0"
32
+
33
+ [tool.ruff]
34
+ line-length = 110
35
+
36
+ [tool.robocop.lint]
37
+ ignore = ["too-few-calls-in-test-case"]
38
+
39
+ [build-system]
40
+ requires = ["poetry-core>=2.0,<3.0"]
41
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,6 @@
1
+ """Public Robot Framework import: ``Library EvidenceReporter``."""
2
+
3
+ from rf_evidence_reporter import __version__
4
+ from rf_evidence_reporter.library import EvidenceReporter
5
+
6
+ __all__ = ["EvidenceReporter", "__version__"]
@@ -0,0 +1,8 @@
1
+ """Robot Framework Evidence Reporter public API."""
2
+
3
+ from .builder import build_reports
4
+ from .library import EvidenceReporter
5
+ from .recorder import EvidenceRecorder
6
+
7
+ __version__ = "0.1.0"
8
+ __all__ = ["EvidenceReporter", "EvidenceRecorder", "build_reports"]
@@ -0,0 +1,103 @@
1
+ """Generate portable HTML from recorded JSON without a browser or Robot execution."""
2
+
3
+ import base64
4
+ import json
5
+ import warnings
6
+ from pathlib import Path
7
+ from datetime import datetime
8
+
9
+ from jinja2 import Environment, PackageLoader, select_autoescape
10
+
11
+ from .recorder import slug, timestamp
12
+
13
+
14
+ def display_date(value):
15
+ if not value:
16
+ return "No registrado"
17
+ return datetime.fromisoformat(value).strftime("%d/%m/%Y · %H:%M:%S")
18
+
19
+
20
+ def timezone_label(value):
21
+ offset = datetime.fromisoformat(value).strftime("%z")
22
+ return "UTC" + offset[:3] + ":" + offset[3:] if offset else "Sin zona horaria"
23
+
24
+
25
+ def build_reports(results_dir, output_dir):
26
+ """Return generated HTML Paths. One HTML per recorded case, never a global dashboard.
27
+
28
+ Invalid case files fail generation clearly. Missing/invalid images become visible warnings.
29
+ Input image references must remain inside their case directory.
30
+ """
31
+ root = Path(results_dir).resolve()
32
+ files = sorted(root.rglob("case.json"))
33
+ if not files:
34
+ raise ValueError(f"NO_CASES: no hay archivos case.json en {root}")
35
+ destination = Path(output_dir).resolve()
36
+ destination.mkdir(parents=True, exist_ok=True)
37
+ environment = Environment(
38
+ loader=PackageLoader("rf_evidence_reporter", "templates"), autoescape=select_autoescape(["html"])
39
+ )
40
+ environment.filters["display_date"] = display_date
41
+ environment.filters["timezone_label"] = timezone_label
42
+ template = environment.get_template("report.html")
43
+ generated = []
44
+ for source in files:
45
+ case = json.loads(source.read_text(encoding="utf-8"))
46
+ if case.get("schema_version") != 1:
47
+ raise ValueError(f"UNSUPPORTED_SCHEMA: {source}")
48
+ # Validate the minimum identity needed for a reliable report filename.
49
+ if not isinstance(case.get("id"), str) or not case["id"] or not isinstance(case.get("name"), str):
50
+ raise ValueError(f"INVALID_CASE: {source}")
51
+ if case.get("status") not in {"PASS", "FAIL", "SKIP", "INCOMPLETE"}:
52
+ raise ValueError(f"INVALID_STATUS: {source}")
53
+ for event in case["events"]:
54
+ event.setdefault("status", "WARN" if event["kind"] == "capture_warning" else "INFO")
55
+ if event["status"] not in {"INFO", "PASS", "WARN", "FAIL"}:
56
+ raise ValueError(f"INVALID_CAPTURE_STATUS: {source}")
57
+ if event.get("image"):
58
+ path = (source.parent / event["image"]).resolve()
59
+ try:
60
+ if not path.is_relative_to(source.parent.resolve()):
61
+ raise ValueError("referencia fuera del directorio del caso")
62
+ from PIL import Image
63
+
64
+ with Image.open(path) as image:
65
+ image.verify()
66
+ mime = {"PNG": "image/png", "JPEG": "image/jpeg", "WEBP": "image/webp"}[image.format]
67
+ event["image_data"] = f"data:{mime};base64,{base64.b64encode(path.read_bytes()).decode()}"
68
+ except (OSError, ValueError, KeyError) as error:
69
+ event.update(
70
+ kind="capture_warning",
71
+ level="WARN",
72
+ status="WARN",
73
+ reason=f"IMAGE_UNAVAILABLE: {error}",
74
+ )
75
+ warnings.warn(f"{source}: {event['reason']}", stacklevel=2)
76
+ case["warning_count"] = sum(
77
+ event.get("level") == "WARN" or event.get("status") == "WARN" for event in case["events"]
78
+ )
79
+ blocks = []
80
+ # Blocks follow first evidence appearance; evidence within each block retains its order.
81
+ direct = [event for event in case["events"] if not event.get("milestone_id")]
82
+ if direct:
83
+ blocks.append({"title": "Evidencias del caso", "description": "", "events": direct})
84
+ for milestone in case["milestones"]:
85
+ blocks.append(
86
+ {
87
+ **milestone,
88
+ "events": [
89
+ event for event in case["events"] if event.get("milestone_id") == milestone["id"]
90
+ ],
91
+ }
92
+ )
93
+ for block in blocks:
94
+ block["warning_count"] = sum(
95
+ event.get("level") == "WARN" or event.get("status") == "WARN" for event in block["events"]
96
+ )
97
+ name = f"{slug(case['name'])}-{slug(case['id'])}.html"
98
+ target = destination / name
99
+ target.write_text(
100
+ template.render(case=case, blocks=blocks, generated_at=timestamp()), encoding="utf-8"
101
+ )
102
+ generated.append(target)
103
+ return generated
@@ -0,0 +1,24 @@
1
+ """CLI shares the public Python generator."""
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ from .builder import build_reports
7
+
8
+
9
+ def main():
10
+ parser = argparse.ArgumentParser(
11
+ prog="rf-evidence", description="HTML individual de evidencias de negocio"
12
+ )
13
+ commands = parser.add_subparsers(dest="command", required=True)
14
+ build = commands.add_parser("build", help="Generar un HTML autocontenido por caso")
15
+ build.add_argument("results_dir")
16
+ build.add_argument("--output", "-o", default="evidence-reports")
17
+ args = parser.parse_args()
18
+ try:
19
+ for path in build_reports(args.results_dir, args.output):
20
+ print(path)
21
+ except (OSError, ValueError, KeyError, TypeError) as error:
22
+ print(f"No se pudo generar el reporte: {error}", file=sys.stderr)
23
+ return 1
24
+ return 0
@@ -0,0 +1,353 @@
1
+ """Robot adapter: explicit evidence keywords and automatic final test metadata."""
2
+
3
+ import io
4
+ from pathlib import Path
5
+ from uuid import uuid4
6
+
7
+ from PIL import Image, ImageGrab
8
+ from robot.api import logger
9
+ from robot.api.deco import keyword, library
10
+ from robot.libraries.BuiltIn import BuiltIn
11
+
12
+ from .recorder import EvidenceRecorder
13
+
14
+
15
+ class CaptureError(RuntimeError):
16
+ def __init__(self, code, reason):
17
+ self.code = code
18
+ super().__init__(f"{code}: {reason}")
19
+
20
+
21
+ @library(scope="GLOBAL", auto_keywords=False)
22
+ class EvidenceReporter:
23
+ """Registra evidencias de negocio independientes por caso de Robot Framework.
24
+
25
+ Las capturas y los mensajes se guardan junto con los datos de ejecución en
26
+ JSON. El HTML se genera después con ``rf-evidence build``; no se genera
27
+ durante las keywords. Los hitos son opcionales: las evidencias sin
28
+ ``milestone_id`` pertenecen directamente al caso.
29
+
30
+ = Importación =
31
+ | Library | EvidenceReporter | output_dir=${OUTPUTDIR}/evidence | strict=${False} |
32
+
33
+ = Argumentos comunes de captura =
34
+ - ``title``: título de negocio visible en el reporte.
35
+ - ``description``: descripción opcional de la evidencia.
36
+ - ``milestone_id``: ID devuelto por `Create Milestone` dentro del mismo caso.
37
+ Si se omite, la evidencia se registra directamente en el caso.
38
+ - ``strict``: si se omite, hereda la configuración de importación.
39
+ Con ``${False}``, un fallo de captura registra WARN y devuelve ``None``.
40
+ Con ``${True}``, conserva la advertencia y falla con ``CAPTURE_FAILED``.
41
+ - ``status``: INFO (predeterminado), PASS, WARN o FAIL. Describe la evidencia;
42
+ no modifica el resultado de ejecución. SKIP solo pertenece a la ejecución.
43
+
44
+ = Errores =
45
+ | Código | Motivo |
46
+ | BROWSER_UNAVAILABLE | SeleniumLibrary no importada o navegador no activo. |
47
+ | ELEMENT_NOT_FOUND | El locator no pudo resolverse. |
48
+ | SCREENSHOT_FAILED | El navegador o elemento no pudo producir la imagen. |
49
+ | DESKTOP_UNAVAILABLE | El backend no pudo capturar el escritorio. |
50
+ | IMAGE_UNAVAILABLE | La imagen solicitada no pudo leerse. |
51
+ | INVALID_IMAGE | Archivo inválido o formato distinto de PNG, JPEG y WEBP. |
52
+ | STORAGE_ERROR | No se pudo guardar el archivo. |
53
+
54
+ Los errores anteriores producen advertencias por defecto. En modo estricto,
55
+ ``CAPTURE_FAILED`` incluye el código y motivo originales. ``NO_ACTIVE_CASE``,
56
+ ``UNKNOWN_MILESTONE``, ``INVALID_LEVEL`` e ``INVALID_CAPTURE_STATUS`` son
57
+ errores de uso y siempre se propagan. Los errores inesperados también se
58
+ propagan; no se convierten silenciosamente en advertencias.
59
+
60
+ = Ejemplo =
61
+ | ${hito}= | Create Milestone | Cliente registrado | Alta confirmada. |
62
+ | Capture Page Evidence | Confirmación de registro | milestone_id=${hito} | status=PASS |
63
+ | Add Evidence Message | Se confirmó el alta del cliente. | milestone_id=${hito} |
64
+
65
+ Cada caso tiene su propio directorio e identificadores, también en Pabot.
66
+ Las capturas se solicitan explícitamente; la librería no decide cuándo
67
+ capturar ni oculta información sensible de las imágenes.
68
+ """
69
+
70
+ ROBOT_LISTENER_API_VERSION = 3
71
+
72
+ def __init__(self, output_dir=None, strict: bool = False):
73
+ """Configura el directorio de evidencias y la política de errores de captura.
74
+
75
+ ``output_dir`` admite una ruta absoluta o relativa al directorio de
76
+ ejecución. Si se omite, utiliza ``${OUTPUTDIR}/evidence`` de Robot.
77
+ ``strict`` es falso por defecto; puede sobrescribirse en cada captura.
78
+ La importación no abre un navegador ni genera reportes HTML.
79
+ """
80
+ self.ROBOT_LIBRARY_LISTENER = self
81
+ self.output_dir = output_dir
82
+ self.strict = strict
83
+ self.recorder = None
84
+
85
+ def start_test(self, data, result):
86
+ root = self.output_dir or Path(BuiltIn().get_variable_value("${OUTPUTDIR}")) / "evidence"
87
+ self.recorder = EvidenceRecorder(root)
88
+ self.recorder.start(result.name, result.parent.full_name, str(data.doc), result.tags)
89
+
90
+ def end_test(self, data, result):
91
+ if self.recorder and self.recorder.case:
92
+ self.recorder.finish(result.status, result.elapsed_time.total_seconds(), result.message)
93
+
94
+ def _active(self, milestone_id=None):
95
+ if self.recorder is None:
96
+ raise RuntimeError("NO_ACTIVE_CASE: no existe un caso de prueba activo.")
97
+ self.recorder.validate_milestone(milestone_id)
98
+ return self.recorder
99
+
100
+ @keyword
101
+ def create_milestone(self, title: str, description: str = ""):
102
+ """Crea un hito de negocio dentro del caso activo y devuelve su ID.
103
+
104
+ ``title`` identifica el hito; ``description`` añade contexto opcional.
105
+ Pasa el ID a las capturas o mensajes mediante ``milestone_id``. No hay
106
+ un hito implícitamente activo: omitir el ID registra evidencia directa.
107
+
108
+ | ${hito}= | Create Milestone | Cuentas consultadas | Saldos disponibles. |
109
+ | Capture Page Evidence | Lista de cuentas | milestone_id=${hito} |
110
+ """
111
+ return self._active().milestone(title, description)
112
+
113
+ @keyword
114
+ def set_report_metadata(self, **metadata):
115
+ """Añade campos personalizados a la tabla de información del caso.
116
+
117
+ Recibe argumentos nombrados ``etiqueta=valor`` y los guarda como texto.
118
+ Si se repite una etiqueta personalizada, conserva el último valor.
119
+ Los campos automáticos de ejecución se mantienen separados. No devuelve
120
+ un valor; falla con ``NO_ACTIVE_CASE`` fuera de un caso activo.
121
+
122
+ | Set Report Metadata | Aplicación=ParaBank | Requerimiento=QA-123 |
123
+ """
124
+ self._active().metadata(metadata)
125
+
126
+ @keyword
127
+ def add_evidence_message(self, message: str, level: str = "INFO", milestone_id=None):
128
+ """Registra un mensaje de negocio y devuelve el ID del evento.
129
+
130
+ ``message`` es el texto del mensaje. ``level`` admite INFO
131
+ (predeterminado), WARN y ERROR, sin distinguir mayúsculas. No modifica
132
+ el resultado del caso. ``milestone_id`` es opcional; debe pertenecer al
133
+ caso activo. Un nivel inválido falla con ``INVALID_LEVEL``.
134
+
135
+ | Add Evidence Message | Se consultaron las cuentas del cliente. |
136
+ | Add Evidence Message | Saldo pendiente de confirmar. | level=WARN |
137
+ """
138
+ level = level.upper()
139
+ if level not in {"INFO", "WARN", "ERROR"}:
140
+ raise ValueError("INVALID_LEVEL: utiliza INFO, WARN o ERROR.")
141
+ return self._active(milestone_id).event(message, level=level, milestone_id=milestone_id)
142
+
143
+ def _driver(self):
144
+ try:
145
+ selenium = BuiltIn().get_library_instance("SeleniumLibrary")
146
+ return selenium.driver
147
+ except Exception as error:
148
+ raise CaptureError(
149
+ "BROWSER_UNAVAILABLE", "SeleniumLibrary no está importada o no hay navegador activo."
150
+ ) from error
151
+
152
+ def _context(self, driver):
153
+ context = {}
154
+ for field, attribute in (("url", "current_url"), ("page_title", "title")):
155
+ try:
156
+ context[field] = getattr(driver, attribute)
157
+ except Exception:
158
+ context[field] = ""
159
+ return context
160
+
161
+ def _image(self, content):
162
+ try:
163
+ with Image.open(io.BytesIO(content)) as image:
164
+ if image.format not in {"PNG", "JPEG", "WEBP"}:
165
+ raise ValueError("solo se admite PNG, JPEG o WEBP")
166
+ image.load()
167
+ return image.format.lower(), image.size
168
+ except Exception as error:
169
+ raise CaptureError("INVALID_IMAGE", str(error)) from error
170
+
171
+ def _capture(self, title, producer, kind, milestone_id, strict, description, status="INFO", context=None):
172
+ status = status.upper()
173
+ if status not in {"INFO", "PASS", "WARN", "FAIL"}:
174
+ raise ValueError("INVALID_CAPTURE_STATUS: utiliza INFO, PASS, WARN o FAIL; SKIP no es válido.")
175
+ recorder = self._active(milestone_id)
176
+ try:
177
+ content, details = producer()
178
+ extension, size = self._image(content)
179
+ path = recorder.case_dir / f"{uuid4().hex}.{extension}"
180
+ try:
181
+ path.write_bytes(content)
182
+ except OSError as error:
183
+ raise CaptureError("STORAGE_ERROR", str(error)) from error
184
+ return recorder.event(
185
+ title,
186
+ kind="capture",
187
+ milestone_id=milestone_id,
188
+ capture_type=kind,
189
+ status=status,
190
+ description=description,
191
+ image=path.name,
192
+ width=size[0],
193
+ height=size[1],
194
+ **details,
195
+ )
196
+ except CaptureError as error:
197
+ reason = str(error)
198
+ except OSError as error:
199
+ reason = f"STORAGE_ERROR: {error}"
200
+ # Unexpected programming errors deliberately propagate.
201
+ try:
202
+ recorder.event(
203
+ title,
204
+ kind="capture_warning",
205
+ level="WARN",
206
+ status="WARN",
207
+ milestone_id=milestone_id,
208
+ capture_type=kind,
209
+ description=description,
210
+ reason=reason,
211
+ **(context or {}),
212
+ )
213
+ except OSError as error:
214
+ logger.warn(f"STORAGE_ERROR: tampoco se pudo guardar la advertencia: {error}")
215
+ logger.warn(f"No se obtuvo la evidencia '{title}'. {reason}")
216
+ if self.strict if strict is None else strict:
217
+ raise CaptureError("CAPTURE_FAILED", reason)
218
+ return None
219
+
220
+ @keyword
221
+ def capture_page_evidence(
222
+ self,
223
+ title: str,
224
+ description: str = "",
225
+ milestone_id=None,
226
+ strict: bool | None = None,
227
+ status: str = "INFO",
228
+ ):
229
+ """Captura el área visible de la página en el navegador activo.
230
+
231
+ Reutiliza la sesión de SeleniumLibrary (Chrome, Edge o Firefox).
232
+ No incluye barras del navegador, escritorio ni la página completa.
233
+ Guarda URL y título de página cuando están disponibles.
234
+
235
+ Consulta los argumentos comunes y errores en la introducción.
236
+ Devuelve el ID del evento, o ``None`` si falla sin modo estricto.
237
+ Sin sesión disponible registra ``BROWSER_UNAVAILABLE``.
238
+
239
+ | Capture Page Evidence | Error mostrado por la aplicación | status=FAIL |
240
+ """
241
+
242
+ def producer():
243
+ driver = self._driver()
244
+ try:
245
+ return driver.get_screenshot_as_png(), self._context(driver)
246
+ except Exception as error:
247
+ raise CaptureError("SCREENSHOT_FAILED", str(error)) from error
248
+
249
+ return self._capture(title, producer, "page", milestone_id, strict, description, status)
250
+
251
+ @keyword
252
+ def capture_element_evidence(
253
+ self,
254
+ locator: str,
255
+ title: str,
256
+ description: str = "",
257
+ milestone_id=None,
258
+ strict: bool | None = None,
259
+ status: str = "INFO",
260
+ ):
261
+ """Captura un elemento de la sesión activa de SeleniumLibrary.
262
+
263
+ ``locator`` utiliza la sintaxis de SeleniumLibrary, por ejemplo
264
+ ``css:.confirmation`` o ``id:registrationForm``. Guarda URL y título de
265
+ página cuando están disponibles. No abre una sesión nueva.
266
+
267
+ Consulta los argumentos comunes y errores en la introducción.
268
+ Devuelve el ID del evento, o ``None`` si falla sin modo estricto.
269
+ Un locator no resuelto registra ``ELEMENT_NOT_FOUND``.
270
+
271
+ | Capture Element Evidence | css:.confirmation | Alta confirmada | status=PASS |
272
+ """
273
+
274
+ def producer():
275
+ driver = self._driver()
276
+ try:
277
+ element = BuiltIn().get_library_instance("SeleniumLibrary").find_element(locator)
278
+ except Exception as error:
279
+ raise CaptureError("ELEMENT_NOT_FOUND", f"{locator}: {error}") from error
280
+ try:
281
+ return element.screenshot_as_png, self._context(driver)
282
+ except Exception as error:
283
+ raise CaptureError("SCREENSHOT_FAILED", str(error)) from error
284
+
285
+ return self._capture(title, producer, "element", milestone_id, strict, description, status)
286
+
287
+ @keyword
288
+ def capture_desktop_evidence(
289
+ self,
290
+ title: str,
291
+ description: str = "",
292
+ milestone_id=None,
293
+ strict: bool | None = None,
294
+ status: str = "INFO",
295
+ ):
296
+ """Captura el escritorio del entorno donde se ejecuta Robot.
297
+
298
+ Utiliza Pillow ImageGrab y no necesita SeleniumLibrary. Requiere un
299
+ entorno gráfico/backend compatible y los permisos del sistema.
300
+ No captura el escritorio de una máquina remota de Selenium Grid.
301
+ En headless, CI o paralelo, el usuario decide si esta captura es útil:
302
+ puede incluir otras ventanas y no se sustituye por una captura de página.
303
+
304
+ Consulta los argumentos comunes y errores en la introducción.
305
+ Devuelve el ID del evento, o ``None`` si falla sin modo estricto.
306
+ Un backend no disponible registra ``DESKTOP_UNAVAILABLE``.
307
+
308
+ | Capture Desktop Evidence | Escritorio al confirmar la operación | status=INFO |
309
+ """
310
+
311
+ def producer():
312
+ try:
313
+ image = ImageGrab.grab()
314
+ buffer = io.BytesIO()
315
+ image.save(buffer, format="PNG")
316
+ return buffer.getvalue(), {}
317
+ except Exception as error:
318
+ raise CaptureError("DESKTOP_UNAVAILABLE", str(error)) from error
319
+
320
+ return self._capture(title, producer, "desktop", milestone_id, strict, description, status)
321
+
322
+ @keyword
323
+ def attach_image_evidence(
324
+ self,
325
+ path: str,
326
+ title: str,
327
+ description: str = "",
328
+ milestone_id=None,
329
+ strict: bool | None = None,
330
+ status: str = "INFO",
331
+ ):
332
+ """Copia una imagen existente al directorio de evidencias del caso.
333
+
334
+ ``path`` admite una ruta absoluta o relativa al directorio de ejecución.
335
+ Acepta PNG, JPEG y WEBP; valida el contenido real de la imagen. El archivo
336
+ original no se modifica y puede moverse después de una copia exitosa.
337
+ No necesita navegador ni entorno gráfico.
338
+
339
+ Consulta los argumentos comunes y errores en la introducción.
340
+ Devuelve el ID del evento, o ``None`` si falla sin modo estricto.
341
+ Una ruta ilegible registra ``IMAGE_UNAVAILABLE``; contenido inválido,
342
+ ``INVALID_IMAGE``.
343
+
344
+ | Attach Image Evidence | ${EXECDIR}/data/confirmacion.png | Confirmación externa | status=PASS |
345
+ """
346
+
347
+ def producer():
348
+ try:
349
+ return Path(path).read_bytes(), {}
350
+ except OSError as error:
351
+ raise CaptureError("IMAGE_UNAVAILABLE", str(error)) from error
352
+
353
+ return self._capture(title, producer, "attachment", milestone_id, strict, description, status)
@@ -0,0 +1,107 @@
1
+ """Append evidence to independent, atomically replaced case files."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from uuid import uuid4
9
+
10
+
11
+ def timestamp():
12
+ return datetime.now().astimezone().isoformat(timespec="milliseconds")
13
+
14
+
15
+ def slug(text):
16
+ return re.sub(r"[^\w-]+", "-", text, flags=re.UNICODE).strip("-")[:80] or "case"
17
+
18
+
19
+ class EvidenceRecorder:
20
+ """One writer per case; no shared manifests, counters or browser state."""
21
+
22
+ def __init__(self, output_dir):
23
+ self.output_dir = Path(output_dir).resolve()
24
+ self.case = None
25
+ self.case_dir = None
26
+
27
+ def start(self, name, suite="", description="", tags=()):
28
+ identifier = uuid4().hex
29
+ self.case_dir = self.output_dir / f"{slug(name)}-{identifier}"
30
+ self.case_dir.mkdir(parents=True, exist_ok=False)
31
+ self.case = {
32
+ "schema_version": 1,
33
+ "id": identifier,
34
+ "name": str(name),
35
+ "suite": str(suite),
36
+ "description": str(description),
37
+ "tags": list(tags),
38
+ "process_id": os.getpid(),
39
+ "started_at": timestamp(),
40
+ "ended_at": None,
41
+ "duration_seconds": None,
42
+ "status": "INCOMPLETE",
43
+ "message": "",
44
+ "metadata": {},
45
+ "milestones": [],
46
+ "events": [],
47
+ }
48
+ self.save()
49
+ return identifier
50
+
51
+ def require_case(self):
52
+ if self.case is None:
53
+ raise RuntimeError("NO_ACTIVE_CASE: la evidencia debe registrarse dentro de un caso de prueba.")
54
+
55
+ def save(self):
56
+ self.require_case()
57
+ target = self.case_dir / "case.json"
58
+ temporary = target.with_suffix(".tmp")
59
+ temporary.write_text(json.dumps(self.case, ensure_ascii=False, indent=2), encoding="utf-8")
60
+ temporary.replace(target)
61
+
62
+ def milestone(self, title, description=""):
63
+ self.require_case()
64
+ identifier = uuid4().hex
65
+ self.case["milestones"].append(
66
+ {"id": identifier, "title": str(title), "description": str(description)}
67
+ )
68
+ self.save()
69
+ return identifier
70
+
71
+ def validate_milestone(self, identifier):
72
+ self.require_case()
73
+ if identifier and not any(item["id"] == identifier for item in self.case["milestones"]):
74
+ raise ValueError("UNKNOWN_MILESTONE: el hito no pertenece al caso activo.")
75
+
76
+ def metadata(self, values):
77
+ self.require_case()
78
+ self.case["metadata"].update({str(key): str(value) for key, value in values.items()})
79
+ self.save()
80
+
81
+ def event(self, title, kind="message", level="INFO", milestone_id=None, **fields):
82
+ self.validate_milestone(milestone_id)
83
+ event = {
84
+ "id": uuid4().hex,
85
+ "title": str(title),
86
+ "kind": kind,
87
+ "level": level,
88
+ "milestone_id": milestone_id,
89
+ "captured_at": timestamp(),
90
+ **fields,
91
+ }
92
+ self.case["events"].append(event)
93
+ self.save()
94
+ return event["id"]
95
+
96
+ def finish(self, status, duration_seconds, message=""):
97
+ self.require_case()
98
+ if status not in {"PASS", "FAIL", "SKIP"}:
99
+ raise ValueError(f"Unsupported Robot status: {status}")
100
+ self.case.update(
101
+ status=status,
102
+ duration_seconds=float(duration_seconds),
103
+ message=str(message),
104
+ ended_at=timestamp(),
105
+ )
106
+ self.save()
107
+ self.case = None
@@ -0,0 +1,80 @@
1
+ <!doctype html>
2
+ <html lang="es" data-theme="light">
3
+ <head>
4
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
5
+ <title>{{ case.name }} · Evidence Reporter</title>
6
+ <style>
7
+ :root{color-scheme:light;--canvas:#f0f4fb;--paper:#fff;--soft:#f7f9fd;--ink:#1c2c47;--muted:#52627c;--line:#dce4f1;--brand:#5744cc;--cyan:#007c91;--info:#1467c2;--pass:#168044;--warn:#a26000;--fail:#c52d4b;--info-bg:#e9f2ff;--pass-bg:#e5f6ec;--warn-bg:#fff3c9;--fail-bg:#ffe8ee;--shadow:0 12px 35px #304d7810}
8
+ [data-theme="dark"]{color-scheme:dark;--canvas:#0d1424;--paper:#182238;--soft:#1d2a43;--ink:#edf3ff;--muted:#a8b9d3;--line:#34435e;--brand:#b1a3ff;--cyan:#5de0ed;--info:#8ac4ff;--pass:#79e7a8;--warn:#ffda72;--fail:#ff98ab;--info-bg:#213b5b;--pass-bg:#173e31;--warn-bg:#45371b;--fail-bg:#482837;--shadow:0 12px 35px #0002}
9
+ *{box-sizing:border-box}body{margin:0;background:var(--canvas);color:var(--ink);font:15px/1.65 system-ui,-apple-system,"Segoe UI",sans-serif}button{font:inherit}button:focus-visible,summary:focus-visible{outline:3px solid var(--cyan);outline-offset:4px}[hidden]{display:none!important}header{background:linear-gradient(120deg,#33277d,#155571);color:#fff;border-bottom:4px solid #47d4d9;padding:23px max(22px,calc((100vw - 1120px)/2))}.header-row{display:flex;justify-content:space-between;align-items:center;gap:20px}.brand{font-weight:800;letter-spacing:.02em}.header-caption{font-size:12px;color:#d4dcff}.theme-switch{border:1px solid #ffffff65;border-radius:24px;color:#fff;background:#ffffff12;padding:8px 16px;cursor:pointer}.theme-switch[aria-checked="true"]{background:#ffffff25}main{max-width:1120px;margin:auto;padding:28px 24px 50px}.tabs{display:flex;gap:6px;padding:6px;background:var(--paper);border:1px solid var(--line);border-radius:14px;margin-bottom:24px;box-shadow:var(--shadow)}.tab{flex:1;border:0;border-radius:10px;padding:12px;color:var(--muted);background:transparent;font-weight:700;cursor:pointer}.tab[aria-selected="true"]{background:var(--brand);color:var(--paper)}[data-theme="dark"] .tab[aria-selected="true"]{color:#151d32}.card,.block{background:var(--paper);border:1px solid var(--line);border-radius:18px;box-shadow:var(--shadow);margin-bottom:24px}.card{padding:30px}.eyebrow{font-size:12px;font-weight:800;color:var(--brand);letter-spacing:.09em;text-transform:uppercase}.title-row{display:flex;justify-content:space-between;align-items:flex-start;gap:24px}h1{font-size:clamp(25px,4vw,36px);line-height:1.25;letter-spacing:-.025em;margin:12px 0 16px;overflow-wrap:anywhere}h2{font-size:24px;line-height:1.35;margin:0}h3{font-size:18px;margin:0}p{margin:8px 0;white-space:pre-wrap;overflow-wrap:anywhere}.muted{color:var(--muted)}.badge{display:inline-flex;align-items:center;gap:8px;font-size:12px;font-weight:850;letter-spacing:.03em;padding:5px 11px;border:1px solid currentColor;border-radius:9px;white-space:nowrap}.execution-status{font-size:17px;padding:12px 20px;margin-top:10px;box-shadow:0 5px 18px #0001}.PASS{color:var(--pass);background:var(--pass-bg)}.FAIL,.ERROR{color:var(--fail);background:var(--fail-bg)}.WARN,.SKIP,.INCOMPLETE{color:var(--warn);background:var(--warn-bg)}.INFO{color:var(--info);background:var(--info-bg)}.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin:24px 0}.stat{padding:16px 20px;background:var(--soft);border-radius:12px;border-top:3px solid var(--brand)}.stat:nth-child(2){border-color:var(--cyan)}.stat:nth-child(3){border-color:var(--warn)}.stat small{display:block;color:var(--muted)}.stat strong{font-size:23px}table{width:100%;border-collapse:collapse}th,td{padding:12px;border-bottom:1px solid var(--line);vertical-align:top;text-align:left;overflow-wrap:anywhere}th{width:26%;color:var(--muted);font-weight:600}tr:last-child>*{border-bottom:0}.block-head{padding:24px 26px;background:var(--soft);border-radius:18px 18px 0 0;border-bottom:1px solid var(--line);border-left:5px solid var(--brand)}.block-head h2{color:var(--brand);margin-top:6px}.number{display:inline-grid;place-items:center;background:var(--brand);color:var(--paper);width:32px;height:32px;border-radius:9px;margin-right:10px;font-size:16px}[data-theme="dark"] .number{color:#151d32}.events{padding:0 26px 24px}.event{padding:22px 0;border-bottom:1px solid var(--line)}.event:last-child{border-bottom:0}.event-head{display:flex;align-items:flex-start;justify-content:space-between;gap:20px}.event-title{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.time{font-size:12px;color:var(--muted)}.capture{border-left:4px solid var(--info);padding-left:18px}.capture[data-status="PASS"]{border-color:var(--pass)}.capture[data-status="WARN"]{border-color:var(--warn)}.capture[data-status="FAIL"]{border-color:var(--fail)}.image-button{display:block;width:100%;margin-top:16px;padding:0;background:var(--soft);border:1px solid var(--line);border-radius:10px;overflow:hidden;cursor:zoom-in}.image-button img{display:block;width:100%;height:auto}.caption{display:flex;justify-content:space-between;gap:12px;font-size:12px;color:var(--muted);margin-top:7px}.context{font-size:12px;color:var(--muted);overflow-wrap:anywhere;margin-top:10px}.note{background:transparent;border-left:4px solid var(--info);padding:10px 16px;margin-top:12px}.note.WARN{border-color:var(--warn)}.note.ERROR{border-color:var(--fail)}.note .badge{background:transparent}.note p{color:var(--ink)}details{border:1px solid var(--line);border-radius:10px;margin-top:18px;background:var(--paper)}summary{padding:13px 16px;cursor:pointer;font-weight:700;color:var(--muted)}details .log-list{padding:0 16px 16px}.log{padding:12px 0}.log+.log{border-top:1px solid var(--line)}.empty{padding:20px 0;color:var(--muted)}.footer{text-align:center;font-size:12px;color:var(--muted);margin-top:30px}.result-message{margin-top:20px}dialog{background:var(--paper);color:var(--ink);border:1px solid var(--line);border-radius:14px;padding:16px;max-width:96vw;max-height:96vh}dialog::backdrop{background:#050b19de}dialog img{display:block;max-width:90vw;max-height:80vh;object-fit:contain}.close{display:block;margin-left:auto;margin-bottom:12px;border:1px solid var(--line);border-radius:8px;background:var(--soft);color:var(--ink);padding:8px 16px;cursor:pointer}@media(max-width:640px){main{padding:20px 12px}.card{padding:20px}.title-row,.event-head{display:block}.stats{grid-template-columns:1fr}.header-row{align-items:flex-start}.header-caption{max-width:200px}.theme-switch{font-size:12px;padding:7px 10px}.block-head{padding:20px}.events{padding:0 18px 20px}.caption{flex-wrap:wrap}.time{display:block;margin-top:8px}th{width:34%}.tab{padding:10px 4px}}
10
+
11
+ .block{margin-top:0}.block-head{display:flex;align-items:center;justify-content:space-between;gap:20px;list-style:none;border-radius:18px;border-bottom:0;color:var(--ink)}.block-head::-webkit-details-marker{display:none}.block[open]>.block-head{border-radius:18px 18px 0 0;border-bottom:1px solid var(--line)}.block-label{display:block;min-width:0}.block-title{display:block;font-size:24px;line-height:1.4;font-weight:800;color:var(--brand);margin:6px 0;overflow-wrap:anywhere}.block-counts{font-size:12px;color:var(--muted);font-weight:500}.disclosure{font-size:28px;color:var(--brand);transition:transform .15s}.block[open]>.block-head .disclosure{transform:rotate(180deg)}.block-description{padding-top:16px}.block-tabs{display:flex;gap:6px;margin-top:20px;border:1px solid var(--line);border-radius:10px;padding:5px;background:var(--soft)}.milestone-toolbar{display:flex;justify-content:space-between;align-items:center;gap:16px;margin-bottom:18px;color:var(--muted);font-size:13px}.milestone-toolbar>div{display:flex;gap:8px;flex-wrap:wrap}.control-button{border:1px solid var(--line);background:var(--paper);color:var(--brand);border-radius:8px;padding:8px 12px;font-weight:700;cursor:pointer}.control-button:hover{background:var(--soft)}@media(max-width:640px){.milestone-toolbar{align-items:flex-start;flex-direction:column}.block-title{font-size:21px}.block-head{padding:18px}.block-counts{display:block}.control-button{font-size:12px}.block-tabs .tab{font-size:13px}}
12
+ .brand svg{width:32px;height:32px;vertical-align:middle;margin-right:8px}
13
+ </style>
14
+ </head>
15
+ <body>
16
+ <header><div class="header-row"><div><div class="brand"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Evidence Reporter">
17
+ <title>Evidence Reporter</title><defs><linearGradient id="brand" x2="1" y2="1"><stop stop-color="#5744cc"/><stop offset="1" stop-color="#155571"/></linearGradient></defs>
18
+ <rect width="64" height="64" rx="16" fill="url(#brand)"/>
19
+ <rect x="14" y="10" width="34" height="44" rx="5" fill="none" stroke="#fff" stroke-width="3"/>
20
+ <rect x="20" y="18" width="22" height="17" rx="3" fill="#b1a3ff"/>
21
+ <path d="m22 31 6-7 5 5 4-4 3 6" fill="none" stroke="#fff" stroke-width="2.5" stroke-linejoin="round"/>
22
+ <path d="M21 42h14M21 47h9" stroke="#fff" stroke-width="2.5" stroke-linecap="round"/>
23
+ <circle cx="47" cy="47" r="12" fill="#47d4d9" stroke="#155571" stroke-width="2"/>
24
+ <path d="m41 47 4 4 8-9" fill="none" stroke="#132a43" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
25
+ </svg>
26
+ Evidence Reporter</div><div class="header-caption">Reporte individual de evidencias de negocio</div></div><button class="theme-switch" id="theme-toggle" role="switch" aria-checked="false" aria-label="Modo oscuro">☾ Modo oscuro</button></div></header>
27
+ <main>
28
+ <nav class="tabs" role="tablist" aria-label="Secciones del reporte" hidden>
29
+ {% for id,label in [('summary','Resumen'),('steps','Pasos'),('logs','Logs')] %}<button class="tab" id="tab-{{ id }}" role="tab" aria-controls="{{ id }}" aria-selected="{{ 'true' if loop.first else 'false' }}" tabindex="{{ '0' if loop.first else '-1' }}">{{ label }}</button>{% endfor %}
30
+ </nav>
31
+ <section id="summary" role="tabpanel" aria-labelledby="tab-summary" tabindex="0">
32
+ <div class="card"><div class="eyebrow">Resumen del caso</div><div class="title-row"><h1>{{ case.name }}</h1><div><div class="muted">Estatus de ejecución</div><span class="badge execution-status {{ case.status }}">{% if case.status == 'PASS' %}✓{% elif case.status == 'FAIL' %}✕{% elif case.status == 'SKIP' %}↷{% else %}!{% endif %} {{ case.status }}</span></div></div>
33
+ {% if case.description %}<p class="muted">{{ case.description }}</p>{% endif %}
34
+ <div class="stats"><div class="stat"><small>Duración</small><strong>{% if case.duration_seconds is not none %}{{ '%.2f'|format(case.duration_seconds) }} s{% else %}Sin finalizar{% endif %}</strong></div><div class="stat"><small>Capturas obtenidas</small><strong>{{ case.events|selectattr('kind','equalto','capture')|list|length }}</strong></div><div class="stat"><small>Advertencias</small><strong>{{ case.warning_count }}</strong></div></div>
35
+ <table aria-label="Información del caso"><tbody><tr><th scope="row">Caso de prueba</th><td>{{ case.name }}</td></tr><tr><th scope="row">Suite</th><td>{{ case.suite }}</td></tr><tr><th scope="row">Inicio de ejecución</th><td><time title="{{ case.started_at }}">{{ case.started_at|display_date }}</time></td></tr><tr><th scope="row">Fin de ejecución</th><td><time title="{{ case.ended_at or '' }}">{{ case.ended_at|display_date }}</time></td></tr>{% for label,value in case.metadata.items() %}<tr><th scope="row">{{ label }}</th><td>{{ value }}</td></tr>{% endfor %}</tbody></table>
36
+ {% if case.message %}<div class="note {% if case.status == 'FAIL' %}ERROR{% else %}WARN{% endif %} result-message"><strong>Resultado</strong><p>{{ case.message }}</p></div>{% endif %}
37
+ {% if case.status == 'INCOMPLETE' %}<div class="note WARN result-message">Ejecución incompleta: se muestran las evidencias conservadas, sin atribuir un resultado final.</div>{% endif %}
38
+ </div></section>
39
+ {% macro logs_list(events) %}{% for event in events %}<article class="log"><div class="event-head"><strong>{{ event.title }}</strong><time class="time" title="{{ event.captured_at }}">{{ event.captured_at|display_date }}</time></div><div class="note {{ event.level }}"><span class="badge {{ event.level }}">{{ event.level }}</span>{% if event.reason %}<p>{{ event.reason }}</p>{% else %}<p>{{ event.title }}</p>{% endif %}</div></article>{% endfor %}{% endmacro %}
40
+ {% macro captures_list(events) %}{% for event in events if event.kind in ['capture','capture_warning'] %}<article class="event"><div class="capture" data-status="{{ event.status }}"><div class="event-head"><div class="event-title"><h3>{{ event.title }}</h3><span class="badge {{ event.status }}">{% if event.status == 'PASS' %}✓{% elif event.status == 'FAIL' %}✕{% elif event.status == 'WARN' %}!{% else %}ⓘ{% endif %} {{ event.status }}</span></div><time class="time" title="{{ event.captured_at }}">{{ event.captured_at|display_date }}</time></div>
41
+ {% if event.kind == 'capture' %}{% if event.description %}<p class="muted">{{ event.description }}</p>{% endif %}<button class="image-button" aria-label="Ampliar: {{ event.title }}"><img src="{{ event.image_data }}" alt="{{ event.title }}" loading="lazy"></button><div class="caption"><span>{{ {'page':'Página visible','element':'Elemento','desktop':'Escritorio','attachment':'Imagen adjunta'}.get(event.capture_type,event.capture_type) }} · {{ event.width }} × {{ event.height }} px</span><span>Ampliar imagen ↗</span></div>{% if event.url %}<div class="context">URL: {{ event.url }}{% if event.page_title %}<br>Página: {{ event.page_title }}{% endif %}</div>{% endif %}
42
+ {% else %}<div class="note WARN"><strong>Evidencia no disponible</strong><p>{{ event.reason }}</p></div>{% endif %}</div></article>{% else %}<p class="empty">Sin capturas en este bloque.</p>{% endfor %}
43
+ {% endmacro %}
44
+ <section id="steps" role="tabpanel" aria-labelledby="tab-steps" tabindex="0">
45
+ {% set milestones = blocks|selectattr('id')|list %}{% if milestones %}<div class="milestone-toolbar" aria-label="Controles de hitos"><span>{{ milestones|length }} hitos de negocio</span><div><button id="expand-milestones" class="control-button">Expandir todos los hitos</button><button id="collapse-milestones" class="control-button">Contraer todos los hitos</button></div></div>{% endif %}
46
+ {% for block in blocks %}{% set block_index = loop.index %}{% set messages = block.events|rejectattr('kind','equalto','capture')|list %}{% set warnings = block.events|selectattr('level','equalto','WARN')|list %}{% set captures = block.events|selectattr('kind','equalto','capture')|list %}
47
+ <details class="block" data-block-kind="{{ 'milestone' if block.id else 'direct' }}" {% if block.id %}open{% endif %}>
48
+ <summary class="block-head"><span class="block-label"><span class="eyebrow">{{ 'Hito de negocio' if block.id else 'Evidencias directas' }}</span><span class="block-title"><span class="number">{{ loop.index }}</span>{{ block.title }}</span><span class="block-counts">{{ captures|length }} capturas · {{ messages|length }} logs · {{ block.warning_count }} advertencias</span></span><span class="disclosure" aria-hidden="true">⌄</span></summary>
49
+ <div class="events">{% if block.description %}<p class="block-description muted">{{ block.description }}</p>{% endif %}
50
+ {% if not block.id %}
51
+ <nav class="block-tabs" role="tablist" aria-label="Contenido de evidencias directas" hidden><button class="tab" id="direct-evidence-tab" role="tab" aria-controls="direct-evidence" aria-selected="true" tabindex="0">Evidencias</button><button class="tab" id="direct-logs-tab" role="tab" aria-controls="direct-logs" aria-selected="false" tabindex="-1">Logs del bloque · {{ messages|length }}</button></nav>
52
+ <div id="direct-evidence" role="tabpanel" aria-labelledby="direct-evidence-tab">{{ captures_list(block.events) }}</div>
53
+ <div id="direct-logs" role="tabpanel" aria-labelledby="direct-logs-tab">{{ logs_list(messages) }}{% if not messages %}<p class="empty">Sin mensajes registrados en este bloque.</p>{% endif %}</div>
54
+ {% else %}
55
+ {{ captures_list(block.events) }}
56
+ {% if messages %}<details class="block-logs"><summary>Logs del bloque · {{ messages|length }}</summary><div class="log-list">{{ logs_list(messages) }}</div></details>{% endif %}
57
+ {% endif %}</div></details>
58
+ {% else %}<div class="card"><h2>Sin evidencias registradas</h2><p class="muted">El resultado se conserva aunque no se hayan llamado keywords de evidencia.</p></div>{% endfor %}
59
+ </section>
60
+ <section id="logs" role="tabpanel" aria-labelledby="tab-logs" tabindex="0"><div class="card"><div class="eyebrow">Registro de mensajes</div><h2>Logs de negocio</h2><p class="muted">Mensajes explícitos y advertencias de captura. Despliega el bloque que quieras consultar.</p>
61
+ {% set counts = namespace(total=0) %}{% for block in blocks %}{% set messages = block.events|rejectattr('kind','equalto','capture')|list %}{% if messages %}{% set counts.total = counts.total + messages|length %}<details><summary>{{ block.title }} · {{ messages|length }}</summary><div class="log-list">{{ logs_list(messages) }}</div></details>{% endif %}{% endfor %}{% if counts.total == 0 %}<p class="empty">Sin logs registrados.</p>{% endif %}
62
+ </div></section>
63
+ <footer class="footer">Zona horaria de ejecución: {{ case.started_at|timezone_label }}<br>Generado: {{ generated_at|display_date }} · {{ generated_at|timezone_label }}<br>Robot Framework Evidence Reporter</footer>
64
+ </main><dialog id="viewer" aria-label="Vista ampliada de evidencia"><button class="close">Cerrar ✕</button><img alt=""></dialog>
65
+ <script>
66
+ const themeButton=document.getElementById('theme-toggle');
67
+ function setTheme(dark){document.documentElement.dataset.theme=dark?'dark':'light';themeButton.setAttribute('aria-checked',String(dark));themeButton.textContent=dark?'☀ Modo claro':'☾ Modo oscuro';}
68
+ let stored;try{stored=localStorage.getItem('evidence-theme');}catch(e){}
69
+ setTheme(stored?stored==='dark':window.matchMedia('(prefers-color-scheme: dark)').matches);
70
+ themeButton.addEventListener('click',()=>{const dark=document.documentElement.dataset.theme!=='dark';setTheme(dark);try{localStorage.setItem('evidence-theme',dark?'dark':'light');}catch(e){}});
71
+ function wireTabs(tablist){const tabs=[...tablist.querySelectorAll(':scope > [role="tab"]')];function activate(tab){tabs.forEach(item=>{const selected=item===tab;item.setAttribute('aria-selected',String(selected));item.tabIndex=selected?0:-1;document.getElementById(item.getAttribute('aria-controls')).hidden=!selected;});}tablist.hidden=false;activate(tabs[0]);tabs.forEach((tab,index)=>{tab.addEventListener('click',()=>activate(tab));tab.addEventListener('keydown',event=>{let next;if(event.key==='ArrowRight')next=(index+1)%tabs.length;else if(event.key==='ArrowLeft')next=(index+tabs.length-1)%tabs.length;else if(event.key==='Home')next=0;else if(event.key==='End')next=tabs.length-1;if(next!==undefined){event.preventDefault();activate(tabs[next]);tabs[next].focus();}});});}
72
+ document.querySelectorAll('.tabs,.block-tabs').forEach(wireTabs);
73
+ const milestones=[...document.querySelectorAll('details[data-block-kind="milestone"]')];
74
+ document.getElementById('expand-milestones')?.addEventListener('click',()=>milestones.forEach(block=>block.open=true));
75
+ document.getElementById('collapse-milestones')?.addEventListener('click',()=>milestones.forEach(block=>block.open=false));
76
+ const dialog=document.getElementById('viewer');let previousFocus;
77
+ document.querySelectorAll('.image-button').forEach(button=>button.addEventListener('click',()=>{previousFocus=button;const image=button.querySelector('img');const enlarged=dialog.querySelector('img');enlarged.src=image.src;enlarged.alt=image.alt;dialog.showModal();}));
78
+ dialog.querySelector('.close').addEventListener('click',()=>dialog.close());dialog.addEventListener('close',()=>{if(previousFocus)previousFocus.focus();});dialog.addEventListener('click',event=>{if(event.target===dialog)dialog.close();});
79
+ </script>
80
+ </body></html>