Compare commits
37 Commits
512c1cea7b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cf6522452d | |||
| d367abe451 | |||
| f5910f763b | |||
| f2da39f0db | |||
| 84f4d2eef8 | |||
| 9297bed50e | |||
| 6660035fa3 | |||
| bd769b0eb0 | |||
| f9185e0f21 | |||
| 3556ac3d76 | |||
| 25ba33d43a | |||
| 896a7413fe | |||
| 1c572a30a0 | |||
| e5cd76c0ba | |||
| 484412b34e | |||
| 9409707329 | |||
| aafc74d876 | |||
| 408411117c | |||
| 22f19a2ced | |||
| ac2174d365 | |||
| 4ef74b38eb | |||
| 214aec2e73 | |||
| f431f1c5dc | |||
| 0550e4a431 | |||
| 2264b76d9c | |||
|
|
4ab3ca0642 | ||
|
|
beeb58c07a | ||
|
|
5d1f518733 | ||
|
|
d2f16e65b2 | ||
|
|
0fe2569131 | ||
|
|
33a629f47d | ||
|
|
e30ff9b178 | ||
|
|
79bfdf78c1 | ||
|
|
e40ca64536 | ||
| 295accd825 | |||
| 87b846b4ae | |||
| 330c2a5364 |
@@ -9,7 +9,7 @@ pipeline {
|
||||
parameters {
|
||||
string(
|
||||
name: 'JENKINS_BASE_URL',
|
||||
defaultValue: 'https://openbokeron.org/jenkins',
|
||||
defaultValue: 'https://openbokeron.uma.es/jenkins',
|
||||
description: 'Base URL del Jenkins objetivo'
|
||||
)
|
||||
string(
|
||||
@@ -36,32 +36,6 @@ pipeline {
|
||||
|
||||
stages {
|
||||
|
||||
/* =========================
|
||||
TESTS
|
||||
========================= */
|
||||
|
||||
stage('Backend: test (main)') {
|
||||
agent {
|
||||
docker {
|
||||
image 'python:3.11-slim'
|
||||
args '-u root'
|
||||
}
|
||||
}
|
||||
steps {
|
||||
dir('backend') {
|
||||
sh '''
|
||||
set -e
|
||||
python -m venv .venv
|
||||
. .venv/bin/activate
|
||||
pip install --upgrade pip
|
||||
pip install -r requirements-dev.txt
|
||||
pytest
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* =========================
|
||||
DOCKER BUILD
|
||||
========================= */
|
||||
@@ -100,6 +74,22 @@ pipeline {
|
||||
}
|
||||
}
|
||||
|
||||
/* =========================
|
||||
TRIVY
|
||||
========================= */
|
||||
|
||||
stage('Security: Trivy job') {
|
||||
agent any
|
||||
steps {
|
||||
build job: 'Trivy Scanner',
|
||||
parameters: [
|
||||
string(name: 'APP_VERSION', value: "${APP_VERSION}")
|
||||
],
|
||||
propagate: true,
|
||||
wait: true
|
||||
}
|
||||
}
|
||||
|
||||
/* =========================
|
||||
DEPLOY
|
||||
========================= */
|
||||
|
||||
@@ -38,11 +38,12 @@ pipeline {
|
||||
agent {
|
||||
docker {
|
||||
image 'python:3.11-slim'
|
||||
args '-u root'
|
||||
}
|
||||
}
|
||||
|
||||
environment {
|
||||
HOME = "${WORKSPACE}"
|
||||
PIP_CACHE_DIR = "${WORKSPACE}/.cache/pip"
|
||||
PYTHONDONTWRITEBYTECODE = 1
|
||||
}
|
||||
|
||||
@@ -50,12 +51,13 @@ pipeline {
|
||||
dir('backend') {
|
||||
sh '''
|
||||
set -e
|
||||
mkdir -p "$PIP_CACHE_DIR" "$WORKSPACE/.cache/pytest"
|
||||
python -m venv .venv
|
||||
. .venv/bin/activate
|
||||
pip install --upgrade pip
|
||||
pip install -r requirements-dev.txt
|
||||
ruff check app tests
|
||||
pytest
|
||||
pytest -o cache_dir="$WORKSPACE/.cache/pytest" --junitxml=pytest.xml
|
||||
'''
|
||||
}
|
||||
}
|
||||
@@ -71,23 +73,95 @@ pipeline {
|
||||
image 'node:20-slim'
|
||||
}
|
||||
}
|
||||
environment {
|
||||
HOME = "${WORKSPACE}"
|
||||
NPM_CONFIG_CACHE = "${WORKSPACE}/.cache/npm"
|
||||
}
|
||||
steps {
|
||||
dir('frontend') {
|
||||
sh '''
|
||||
set -e
|
||||
mkdir -p "$NPM_CONFIG_CACHE"
|
||||
npm install --no-progress --no-audit --prefer-offline
|
||||
npm run check
|
||||
mkdir -p test-results
|
||||
npm test
|
||||
npm run build
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Cleanup') {
|
||||
agent any
|
||||
steps {
|
||||
cleanWs()
|
||||
}
|
||||
|
||||
post {
|
||||
always {
|
||||
script {
|
||||
node {
|
||||
junit testResults: 'backend/pytest.xml', allowEmptyResults: true
|
||||
junit testResults: 'frontend/test-results/junit.xml', allowEmptyResults: true
|
||||
|
||||
if (env.CHANGE_ID) {
|
||||
def giteaBase = 'https://openbokeron.uma.es'
|
||||
def owner = 'OpenBokeron'
|
||||
def repo = 'TallerCiCd'
|
||||
def pr = env.CHANGE_ID
|
||||
|
||||
def result = currentBuild.currentResult
|
||||
def msg = ""
|
||||
|
||||
if (result == "SUCCESS") {
|
||||
msg = """
|
||||
✅ Todo en orden, camarada.
|
||||
|
||||
- URL: ${env.BUILD_URL}
|
||||
""".stripIndent().trim()
|
||||
}
|
||||
else if (result == "FAILURE") {
|
||||
msg = """
|
||||
❌ Alto ahí. ¿Qué clase de crímenes de guerra has cometido en esta PR?
|
||||
|
||||
- URL: ${env.BUILD_URL}
|
||||
""".stripIndent().trim()
|
||||
}
|
||||
else {
|
||||
msg = """
|
||||
⚠️ Algo ha pasado, no sé si es culpa tuyao mía. Por probabilidad, pensaré que la has pifiado tú.
|
||||
|
||||
- URL: ${env.BUILD_URL}
|
||||
""".stripIndent().trim()
|
||||
}
|
||||
|
||||
def commentsUrl = "${giteaBase}/gitea/api/v1/repos/${owner}/${repo}/issues/${pr}/comments"
|
||||
|
||||
withCredentials([usernamePassword(credentialsId: 'jenkins-bot-api',
|
||||
usernameVariable: 'GITEA_USER',
|
||||
passwordVariable: 'GITEA_TOKEN')]) {
|
||||
|
||||
def body = msg.replace('\\', '\\\\').replace('"','\\"').replace('\n','\\n')
|
||||
|
||||
// Avoid interpolation of secret variables (https://www.jenkins.io/doc/book/pipeline/jenkinsfile/#string-interpolation)
|
||||
withEnv(["GITEA_COMMENTS_URL=${commentsUrl}", "GITEA_BODY=${body}"]) {
|
||||
sh(label: 'Comentar en PR (Gitea)', script: '''
|
||||
curl -sS -X POST \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary @- \
|
||||
"$GITEA_COMMENTS_URL" <<JSON
|
||||
{"body":"$GITEA_BODY"}
|
||||
JSON
|
||||
''')
|
||||
}
|
||||
}
|
||||
} else {
|
||||
echo "No es build de PR (CHANGE_ID vacío); no comento."
|
||||
}
|
||||
|
||||
cleanWs()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
91
README.md
91
README.md
@@ -4,8 +4,11 @@
|
||||
- `frontend/`: interfaz en Svelte (Vite).
|
||||
|
||||
## Requisitos locales
|
||||
- Python 3.11+ y `pip`
|
||||
- Ubuntu/Debian: `python3`, `python3-venv` y `python3-pip`
|
||||
- Node 18+ y `npm`
|
||||
- Docker y Docker Compose (solo para la opcion con contenedores)
|
||||
|
||||
En muchas distros `python` no existe o apunta a otra versión. Para evitar problemas, en esta guia se usa siempre `python3`.
|
||||
|
||||
## Configuracion de entorno (local/prod)
|
||||
Variables que usa `docker-compose` y el frontend:
|
||||
@@ -14,59 +17,107 @@ cp .env.example .env
|
||||
```
|
||||
Notas:
|
||||
- `VITE_API_BASE` por defecto apunta a `/taller/api` y el frontend proxya a la API.
|
||||
- Para Jenkins local en contenedor: `JENKINS_BASE_URL=http://jenkins:8080`. Se hace necesario que back y jenkins estén en la misma red de Docker si se quiere probar en local.
|
||||
- Para VPS: `JENKINS_BASE_URL=https://openbokeron.org/jenkins`.
|
||||
- El archivo `.env` se usa para el despliegue con `docker compose` y para inyectar variables en las imágenes/contenedores. No hace falta para arrancar backend y frontend a mano en local.
|
||||
- `JENKINS_BASE_URL` no suele funcionar en local tal como viene en el ejemplo. Esa variable está pensada para entornos desplegados, donde se sustituye por la URL pública real del Jenkins accesible desde el servidor o VPS.
|
||||
- Si se quisiera probar Jenkins también en local con contenedores, backend y Jenkins tendrían que compartir red Docker y usar una URL interna como `http://jenkins:8080`.
|
||||
- Para tener Jenkins local hay dos opciones sencillas: levantar el contenedor incluido en `jenkins/jenkins-compose.yml` o instalar Jenkins directamente en la máquina host.
|
||||
- La opcion con contenedor puede dar guerra porque aquí se trabaja con un esquema tipo Docker-outside-of-Docker. Si da problemas, suele ser más simple instalar Jenkins en la máquina anfitriona y ajustar `JENKINS_BASE_URL` en el `.env` a esa URL local.
|
||||
|
||||
## Backend (FastAPI)
|
||||
## Arranque local sin Docker
|
||||
|
||||
Abre dos terminales: una para el backend y otra para el frontend.
|
||||
|
||||
### Backend (FastAPI)
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv .venv
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --reload --port 8000
|
||||
python3 -m pip install -r requirements.txt
|
||||
python3 -m uvicorn app.main:app --reload --port 8000
|
||||
```
|
||||
|
||||
Si todo va bien, la API queda disponible en `http://127.0.0.1:8000`.
|
||||
|
||||
Importante: deja esa terminal abierta y con el entorno virtual activado mientras trabajas con el backend.
|
||||
|
||||
Endpoints:
|
||||
- `GET /health` estado.
|
||||
- `GET /menu` devuelve el menú del día.
|
||||
- `GET /prices` lista de precios aleatorios.
|
||||
- `GET /prices/{item}` precio aleatorio para un item concreto.
|
||||
|
||||
Tests y lint:
|
||||
Tests y lint del backend:
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements-dev.txt
|
||||
source .venv/bin/activate
|
||||
python3 -m pip install -r requirements-dev.txt
|
||||
pytest
|
||||
ruff check app tests
|
||||
```
|
||||
|
||||
Docker:
|
||||
```bash
|
||||
cd backend
|
||||
docker build -t cafeteria-backend .
|
||||
docker run -p 8000:8000 cafeteria-backend
|
||||
```
|
||||
Si abres otra terminal, tienes que volver a activar el entorno virtual con `source .venv/bin/activate` antes de lanzar `pytest`, `ruff` o cualquier comando del backend.
|
||||
|
||||
## Frontend (Svelte)
|
||||
### Frontend (Svelte)
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev -- --host --port 5173
|
||||
```
|
||||
|
||||
Abrir `http://localhost:5173/taller/`.
|
||||
Tests, lint/check:
|
||||
|
||||
Tests y check del frontend:
|
||||
```bash
|
||||
cd frontend
|
||||
npm test # no, no voy a hacer tests de frontend
|
||||
npm test
|
||||
npm run check
|
||||
```
|
||||
|
||||
Docker (sirve con nginx):
|
||||
## Arranque con Docker Compose
|
||||
|
||||
Desde la raíz del proyecto:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Servicios disponibles:
|
||||
- Frontend: `http://localhost:8081/taller/`
|
||||
- Backend: `http://localhost:8000/health`
|
||||
|
||||
Para parar los contenedores:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Si quieres reconstruir imágenes despues de cambios grandes:
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
## Arranque con Docker manual (sin Compose)
|
||||
|
||||
### Backend
|
||||
```bash
|
||||
cd backend
|
||||
docker build -t cafeteria-backend .
|
||||
docker run --rm -p 8000:8000 cafeteria-backend
|
||||
```
|
||||
|
||||
### Frontend
|
||||
```bash
|
||||
cd frontend
|
||||
docker build -t cafeteria-frontend .
|
||||
docker run -p 8080:80 cafeteria-frontend
|
||||
docker run --rm -p 8081:8081 cafeteria-frontend
|
||||
```
|
||||
|
||||
Abrir `http://localhost:8081/taller/`.
|
||||
|
||||
Nota: el contenedor del frontend está preparado para funcionar bien junto a `docker compose`, donde puede resolver el servicio `backend` por red interna. Si lanzas ambos contenedores manualmente por separado, tendrás que conectarlos a una red común y ajustar la resolución entre ellos.
|
||||
|
||||
|
||||
## Jenkinsfile (pipeline)
|
||||
Incluido `Jenkinsfile` declarativo para ejecutar en un agente cualquiera con Python 3.11+, Node 18+ y Docker:
|
||||
|
||||
@@ -57,7 +57,7 @@ def health():
|
||||
"commit": settings.git_commit,
|
||||
"build": settings.build_number,
|
||||
"author": settings.commit_author,
|
||||
"uptime_seconds": uptime()
|
||||
"uptime_seconds": uptime(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import base64
|
||||
from typing import Dict, List
|
||||
import html
|
||||
import re
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
@@ -26,25 +28,74 @@ def _sort_builds(builds: List[Dict]) -> List[Dict]:
|
||||
return sorted(builds, key=lambda build: build.get("number", 0), reverse=True)
|
||||
|
||||
|
||||
def normalize_build(build: Dict) -> Dict:
|
||||
changes = build.get("changeSets", [])
|
||||
def _extract_commits(build: Dict) -> List[Dict]:
|
||||
commits = []
|
||||
|
||||
for cs in changes:
|
||||
for cs in build.get("changeSets", []):
|
||||
for item in cs.get("items", []):
|
||||
commits.append({
|
||||
"commit": item.get("commitId", "")[:7],
|
||||
"message": item.get("msg", ""),
|
||||
"author": item.get("author", {}).get("fullName", "unknown"),
|
||||
})
|
||||
commits.append(
|
||||
{
|
||||
"commit": item.get("commitId", "")[:7],
|
||||
"message": item.get("msg", ""),
|
||||
"author": item.get("author", {}).get("fullName", "unknown"),
|
||||
}
|
||||
)
|
||||
return commits
|
||||
|
||||
|
||||
def _extract_trigger(build: Dict) -> str:
|
||||
for action in build.get("actions", []):
|
||||
for cause in action.get("causes", []):
|
||||
description = cause.get("shortDescription")
|
||||
if description:
|
||||
return html.unescape(description).strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _summarize_trigger(trigger: str) -> Tuple[str, str, str]:
|
||||
if not trigger:
|
||||
return "", "", ""
|
||||
|
||||
pr_url = ""
|
||||
for line in trigger.splitlines():
|
||||
if line.strip().lower().startswith("reviewed-on:"):
|
||||
pr_url = line.split(":", 1)[-1].strip()
|
||||
break
|
||||
|
||||
author_match = re.search(r"\(([^)]+)\)\.", trigger)
|
||||
author = author_match.group(1).strip() if author_match else ""
|
||||
|
||||
pr_match = re.search(r"#(\d+)", trigger)
|
||||
title_match = re.search(r"Merge pull request '([^']+)'", trigger)
|
||||
|
||||
if pr_match:
|
||||
pr_number = pr_match.group(1)
|
||||
title = title_match.group(1).strip() if title_match else ""
|
||||
if title:
|
||||
return f"Merge PR #{pr_number} · {title}", pr_url, author
|
||||
return f"Merge PR #{pr_number}", pr_url, author
|
||||
|
||||
return trigger.splitlines()[0].strip(), pr_url, author
|
||||
|
||||
|
||||
def normalize_build(build: Dict) -> Dict:
|
||||
commits = _extract_commits(build)
|
||||
trigger = _extract_trigger(build)
|
||||
trigger_label, trigger_url, trigger_author = _summarize_trigger(trigger)
|
||||
status = (build.get("result") or "RUNNING").lower()
|
||||
if build.get("building"):
|
||||
status = "running"
|
||||
|
||||
return {
|
||||
"number": build.get("number"),
|
||||
"status": (build.get("result") or "RUNNING").lower(),
|
||||
"status": status,
|
||||
"finished_at": build.get("timestamp"),
|
||||
"duration_seconds": build.get("duration", 0) // 1000,
|
||||
"url": build.get("url"),
|
||||
"commits": commits,
|
||||
"trigger": trigger,
|
||||
"trigger_label": trigger_label,
|
||||
"trigger_url": trigger_url,
|
||||
"trigger_author": trigger_author,
|
||||
}
|
||||
|
||||
|
||||
@@ -61,15 +112,17 @@ def fetch_builds(limit: int = 5) -> List[Dict]:
|
||||
raise ValueError("JENKINS_JOB_NAME not configured")
|
||||
url = (
|
||||
f"{settings.jenkins_base_url}/job/{settings.jenkins_job_name}/api/json"
|
||||
"?tree=builds[number,url,result,timestamp,duration,"
|
||||
"changesets[items[commitId,msg,author[fullName]]]]"
|
||||
"?tree=builds[number,url,result,timestamp,duration,building,"
|
||||
"changeSets[items[commitId,msg,author[fullName]]],"
|
||||
"actions[causes[shortDescription]]]"
|
||||
)
|
||||
|
||||
resp = requests.get(url, headers = _auth_header(), timeout=5)
|
||||
resp = requests.get(url, headers=_auth_header(), timeout=5)
|
||||
resp.raise_for_status()
|
||||
|
||||
builds = resp.json().get("builds", [])
|
||||
return builds[:limit]
|
||||
return _sort_builds(builds)[:limit]
|
||||
|
||||
|
||||
def build_history() -> Dict:
|
||||
"""Return Jenkins build history data."""
|
||||
|
||||
@@ -54,6 +54,7 @@ def _pick_mains(count: int = ITEMS_PER_SECTION) -> List[str]:
|
||||
random.shuffle(mains)
|
||||
return mains
|
||||
|
||||
|
||||
def _pick_garnish() -> List[str]:
|
||||
garnish_options = MENU_SOURCE["mains"]["garnish"]
|
||||
|
||||
@@ -69,6 +70,7 @@ def _build_alternative() -> Dict:
|
||||
"note": alternative.get("note", ""),
|
||||
}
|
||||
|
||||
|
||||
def build_menu(items_per_section: int = ITEMS_PER_SECTION) -> Dict:
|
||||
today = datetime.now()
|
||||
|
||||
|
||||
@@ -24,10 +24,7 @@ class RuntimeConfig:
|
||||
git_commit: str = os.getenv("GIT_COMMIT", "local")
|
||||
build_number: str = os.getenv("BUILD_NUMBER", "-")
|
||||
commit_author: str = os.getenv("COMMIT_AUTHOR", "local")
|
||||
jenkins_base_url: str = os.getenv(
|
||||
"JENKINS_BASE_URL",
|
||||
"http://localhost:8080"
|
||||
).rstrip("/")
|
||||
jenkins_base_url: str = os.getenv("JENKINS_BASE_URL", "http://localhost:8080").rstrip("/")
|
||||
jenkins_job_name: str = os.getenv("JENKINS_JOB_NAME", "")
|
||||
jenkins_user: str = os.getenv("JENKINS_USER", "")
|
||||
jenkins_token: str = os.getenv("JENKINS_TOKEN", "")
|
||||
|
||||
@@ -61,6 +61,15 @@ def test_build_history(monkeypatch):
|
||||
"timestamp": 1719992400000,
|
||||
"duration": 75000,
|
||||
"url": "http://jenkins.local/job/demo/205",
|
||||
"actions": [
|
||||
{
|
||||
"causes": [
|
||||
{
|
||||
"shortDescription": "Triggered by Merge pull request #37",
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"changeSets": [
|
||||
{
|
||||
"items": [
|
||||
@@ -79,6 +88,7 @@ def test_build_history(monkeypatch):
|
||||
"timestamp": 1719988800000,
|
||||
"duration": 1500,
|
||||
"url": "http://jenkins.local/job/demo/204",
|
||||
"actions": [],
|
||||
"changeSets": [],
|
||||
},
|
||||
]
|
||||
@@ -101,12 +111,20 @@ def test_build_history(monkeypatch):
|
||||
assert first["commits"] == [
|
||||
{"commit": "9ac3f91", "message": "Anade la API de Jenkins", "author": "Dev One"}
|
||||
]
|
||||
assert first["trigger"] == "Triggered by Merge pull request #37"
|
||||
assert first["trigger_label"] == "Merge PR #37"
|
||||
assert first["trigger_url"] == ""
|
||||
assert first["trigger_author"] == ""
|
||||
|
||||
second = builds[1]
|
||||
assert second["number"] == 204
|
||||
assert second["status"] == "running"
|
||||
assert second["duration_seconds"] == 1
|
||||
assert second["commits"] == []
|
||||
assert second["trigger"] == ""
|
||||
assert second["trigger_label"] == ""
|
||||
assert second["trigger_url"] == ""
|
||||
assert second["trigger_author"] == ""
|
||||
|
||||
|
||||
def test_build_history_error_returns_empty(monkeypatch):
|
||||
|
||||
1
frontend/.gitignore
vendored
1
frontend/.gitignore
vendored
@@ -6,3 +6,4 @@ dist
|
||||
*.log
|
||||
.svelte-kit
|
||||
vite.config.ts.timestamp-*
|
||||
TESTS.md
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-check",
|
||||
"test": "vitest"
|
||||
"test": "vitest run --reporter=default --reporter=junit --outputFile=./test-results/junit.xml"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^3.0.1",
|
||||
|
||||
@@ -377,12 +377,25 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
<span class={`status-dot ${build.status}`}></span>
|
||||
<span class="summary-title">#{build.number}</span>
|
||||
|
||||
{#if build.commits.length === 0}
|
||||
<span class="muted">Ejecución manual</span>
|
||||
{:else}
|
||||
{#if build.trigger_label || build.trigger}
|
||||
<div class="summary-trigger">
|
||||
{#if build.trigger_url}
|
||||
<a href={build.trigger_url} target="_blank" rel="noreferrer">
|
||||
{build.trigger_label || build.trigger}
|
||||
</a>
|
||||
{:else}
|
||||
<span>{build.trigger_label || build.trigger}</span>
|
||||
{/if}
|
||||
{#if build.trigger_author}
|
||||
<span class="trigger-author">{build.trigger_author}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if build.commits.length > 0}
|
||||
<span class="summary-commit">
|
||||
{build.commits[0].commit} · {build.commits[0].author}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="muted">Ejecución manual</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -394,7 +407,36 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
</summary>
|
||||
|
||||
<div class="history-body">
|
||||
{#if build.trigger_label || build.trigger}
|
||||
<p class="history-row">
|
||||
<span>Disparo</span>
|
||||
<span>
|
||||
{#if build.trigger_url}
|
||||
<a
|
||||
class="history-link"
|
||||
href={build.trigger_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{build.trigger_label || build.trigger}
|
||||
</a>
|
||||
{:else}
|
||||
{build.trigger_label || build.trigger}
|
||||
{/if}
|
||||
{#if build.trigger_author}
|
||||
<span class="trigger-author">{build.trigger_author}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if build.commits.length > 0}
|
||||
<p class="history-row">
|
||||
<span>Commit</span>
|
||||
<span class="mono">
|
||||
{build.commits[0].commit} · {build.commits[0].author}
|
||||
</span>
|
||||
</p>
|
||||
<p class="history-row">
|
||||
<span>Mensaje</span>
|
||||
<span>{build.commits[0].message}</span>
|
||||
@@ -408,6 +450,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
{#if build.status === 'failure'}
|
||||
<p class="history-message danger">Build fallida</p>
|
||||
{:else if build.status === 'running'}
|
||||
<p class="history-message running">En curso</p>
|
||||
{:else}
|
||||
<p class="history-message success">Correcto</p>
|
||||
{/if}
|
||||
|
||||
127
frontend/src/App.test.js
Normal file
127
frontend/src/App.test.js
Normal file
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
CI/CD Workshop
|
||||
Copyright (C) 2025 OpenBokeron
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/svelte';
|
||||
import App from './App.svelte';
|
||||
|
||||
const mockMenu = {
|
||||
starters: ['Sopa de cocido', 'Ensalada mixta'],
|
||||
mains: ['Pescado al vapor', 'Pollo asado'],
|
||||
garnish: ['Patatas fritas'],
|
||||
desserts: ['Fruta del tiempo', 'Yogur'],
|
||||
notes: ['Pan y bebida incluidos'],
|
||||
menu_price: 5.5,
|
||||
university_deal: {
|
||||
old_price: 4.5,
|
||||
current_price: 5.5,
|
||||
note: 'Gracias al convenio con la Universidad',
|
||||
},
|
||||
alternative: { title: 'Plato alternativo', items: [], price: 5.0 },
|
||||
availability: { last_updated: '12:00' },
|
||||
espetos_tip: 'Hoy toca sardinas',
|
||||
};
|
||||
|
||||
const mockPrices = {
|
||||
items: [
|
||||
{ item: 'cafe', price: 1.2, currency: 'EUR', generated_at: '10:00' },
|
||||
{ item: 'tostada', price: 2.0, currency: 'EUR', generated_at: '10:00' },
|
||||
],
|
||||
};
|
||||
|
||||
const mockHealth = {
|
||||
status: 'ok',
|
||||
build: 42,
|
||||
commit: 'abc1234def5678',
|
||||
author: 'Test Author',
|
||||
uptime_seconds: 3600,
|
||||
};
|
||||
|
||||
const mockBuilds = {
|
||||
builds: [
|
||||
{
|
||||
number: 42,
|
||||
status: 'success',
|
||||
finished_at: Date.now(),
|
||||
duration_seconds: 60,
|
||||
commits: [{ commit: 'abc1234', message: 'Fix bug', author: 'Dev' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function createMockFetch(url) {
|
||||
if (url.includes('/menu')) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockMenu) });
|
||||
}
|
||||
if (url.includes('/prices')) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockPrices) });
|
||||
}
|
||||
if (url.includes('/health')) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockHealth) });
|
||||
}
|
||||
if (url.includes('/builds')) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockBuilds) });
|
||||
}
|
||||
return Promise.resolve({ ok: false, status: 404 });
|
||||
}
|
||||
|
||||
describe('App.svelte', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.fetch = vi.fn(createMockFetch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renderiza el titulo principal', async () => {
|
||||
render(App);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renderiza la seccion hero con eyebrow', async () => {
|
||||
render(App);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Taller CI/CD con Jenkins')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renderiza los botones de accion', async () => {
|
||||
render(App);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Refrescar menú')).toBeInTheDocument();
|
||||
expect(screen.getByText('Recalcular desayunos')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renderiza la tarjeta de Open Bokeron', async () => {
|
||||
render(App);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Open Bokeron')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Somos Open Bokeron, la asociación de software libre de la ETSII.')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
123
frontend/src/services/api.test.js
Normal file
123
frontend/src/services/api.test.js
Normal file
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
CI/CD Workshop
|
||||
Copyright (C) 2025 OpenBokeron
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { getMenu, getPrices, getCiStatus, getBuildHistory } from './api';
|
||||
|
||||
describe('API services', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('getMenu', () => {
|
||||
it('devuelve datos del menu cuando la API responde correctamente', async () => {
|
||||
const mockMenu = {
|
||||
starters: ['Sopa'],
|
||||
mains: ['Pescado al vapor'],
|
||||
desserts: ['Fruta'],
|
||||
};
|
||||
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockMenu),
|
||||
});
|
||||
|
||||
const result = await getMenu();
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith('/taller/api/menu');
|
||||
expect(result).toEqual(mockMenu);
|
||||
});
|
||||
|
||||
it('lanza error cuando la API falla', async () => {
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
});
|
||||
|
||||
await expect(getMenu()).rejects.toThrow('Respuesta no valida del servidor');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPrices', () => {
|
||||
it('devuelve array de items cuando la API responde', async () => {
|
||||
const mockResponse = {
|
||||
items: [
|
||||
{ item: 'cafe', price: 1.2 },
|
||||
{ item: 'tostada', price: 2.0 },
|
||||
],
|
||||
};
|
||||
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockResponse),
|
||||
});
|
||||
|
||||
const result = await getPrices();
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith('/taller/api/prices');
|
||||
expect(result).toEqual(mockResponse.items);
|
||||
});
|
||||
|
||||
it('devuelve array vacio si no hay items', async () => {
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({}),
|
||||
});
|
||||
|
||||
const result = await getPrices();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCiStatus', () => {
|
||||
it('llama al endpoint /health', async () => {
|
||||
const mockHealth = { status: 'ok', build: 42 };
|
||||
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockHealth),
|
||||
});
|
||||
|
||||
const result = await getCiStatus();
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith('/taller/api/health');
|
||||
expect(result).toEqual(mockHealth);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBuildHistory', () => {
|
||||
it('llama al endpoint /builds', async () => {
|
||||
const mockBuilds = { builds: [{ number: 1 }, { number: 2 }] };
|
||||
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockBuilds),
|
||||
});
|
||||
|
||||
const result = await getBuildHistory();
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith('/taller/api/builds');
|
||||
expect(result).toEqual(mockBuilds);
|
||||
});
|
||||
});
|
||||
});
|
||||
19
frontend/src/setupTests.js
Normal file
19
frontend/src/setupTests.js
Normal file
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
CI/CD Workshop
|
||||
Copyright (C) 2025 OpenBokeron
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
@@ -697,6 +697,25 @@ li {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.summary-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
color: #fbbf24;
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.summary-trigger a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.summary-trigger a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.summary-meta {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
@@ -723,6 +742,11 @@ li {
|
||||
box-shadow: 0 0 8px rgba(248, 113, 113, 0.5);
|
||||
}
|
||||
|
||||
.status-dot.running {
|
||||
background: #facc15;
|
||||
box-shadow: 0 0 8px rgba(250, 204, 21, 0.5);
|
||||
}
|
||||
|
||||
.history-body {
|
||||
padding: 0.75rem 0.9rem 0.9rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
@@ -743,6 +767,21 @@ li {
|
||||
color: #cbd5f5;
|
||||
}
|
||||
|
||||
.history-link {
|
||||
color: #fbbf24;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.history-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #94a3b8;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.history-message {
|
||||
margin: 0.2rem 0 0;
|
||||
background: rgba(248, 113, 113, 0.12);
|
||||
@@ -758,6 +797,12 @@ li {
|
||||
color: #bbf7d0;
|
||||
}
|
||||
|
||||
.history-message.running {
|
||||
background: rgba(250, 204, 21, 0.16);
|
||||
border-color: rgba(250, 204, 21, 0.5);
|
||||
color: #fef9c3;
|
||||
}
|
||||
|
||||
.history-item[open] {
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
@@ -770,6 +815,10 @@ li {
|
||||
border-color: rgba(248, 113, 113, 0.35);
|
||||
}
|
||||
|
||||
.history-item.running {
|
||||
border-color: rgba(250, 204, 21, 0.35);
|
||||
}
|
||||
|
||||
.chip.danger {
|
||||
background: rgba(248, 113, 113, 0.2);
|
||||
color: #fecdd3;
|
||||
@@ -820,3 +869,15 @@ li {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.trigger-author {
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, 0.16);
|
||||
color: #cbd5f5;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.history-row .trigger-author {
|
||||
margin-left: 0.35rem;
|
||||
}
|
||||
|
||||
38
frontend/src/utils/text.test.js
Normal file
38
frontend/src/utils/text.test.js
Normal file
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
CI/CD Workshop
|
||||
Copyright (C) 2025 OpenBokeron
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { prettify } from './text';
|
||||
|
||||
describe('prettify', () => {
|
||||
it('convierte snake_case a Title Case', () => {
|
||||
expect(prettify('hello_world')).toBe('Hello World');
|
||||
});
|
||||
|
||||
it('maneja palabras multiples con underscore', () => {
|
||||
expect(prettify('cafe_con_leche')).toBe('Cafe Con Leche');
|
||||
});
|
||||
|
||||
it('maneja una sola palabra sin underscore', () => {
|
||||
expect(prettify('bocadillo')).toBe('Bocadillo');
|
||||
});
|
||||
|
||||
it('maneja cadena vacia', () => {
|
||||
expect(prettify('')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ export default defineConfig(({ mode }) => {
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./src/setupTests.js'],
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
|
||||
Reference in New Issue
Block a user