Compare commits

...

39 Commits

Author SHA1 Message Date
d367abe451 Merge pull request 'Show author info' (#40) from feature/main-38-JenkinsAPIUsage into main
Reviewed-on: #40
2026-02-22 13:42:04 +01:00
f5910f763b Merge branch 'main' into feature/main-38-JenkinsAPIUsage
All checks were successful
Tests / Declarative: Post Actions passed: 14
CI-Multi/pipeline/pr-main This commit looks good
2026-02-22 13:40:51 +01:00
f2da39f0db Show author info
All checks were successful
Tests / Declarative: Post Actions passed: 14
CI-Multi/pipeline/pr-main This commit looks good
2026-02-22 13:38:30 +01:00
84f4d2eef8 Merge pull request 'Better use of Jenkins API in frontend' (#39) from feature/main-38-JenkinsAPIUsage into main
Reviewed-on: #39
2026-02-22 13:20:08 +01:00
9297bed50e Update front & back
All checks were successful
Tests / Declarative: Post Actions passed: 14
CI-Multi/pipeline/pr-main This commit looks good
2026-02-22 13:16:54 +01:00
6660035fa3 Merge pull request 'Add Trivy stage to CD' (#37) from feature/main-36-AddTrivyJob into main
Reviewed-on: #37
2026-02-22 12:24:21 +01:00
bd769b0eb0 Format files
All checks were successful
Tests / Declarative: Post Actions passed: 14
CI-Multi/pipeline/pr-main This commit looks good
2026-02-22 12:20:30 +01:00
f9185e0f21 Merge pull request 'Replace openbokeron.org with uma.es' (#35) from bugfix/main-34-UpdateOBURL into main
Reviewed-on: #35
2026-02-16 20:36:28 +01:00
3556ac3d76 Update url
All checks were successful
Tests / Declarative: Post Actions passed: 14
CI-Multi/pipeline/pr-main This commit looks good
2026-02-16 20:28:25 +01:00
25ba33d43a Merge pull request 'Update jenkinsfile.ci' (#30) from feature/main-UpdateJenkinsFileCi into main
Reviewed-on: #30
2026-02-06 21:39:23 +01:00
896a7413fe Update Jenkinsfile.ci
All checks were successful
Tests / Declarative: Post Actions passed: 14
CI-Multi/pipeline/pr-main This commit looks good
2026-02-06 21:35:56 +01:00
1c572a30a0 Delete build from CD job 2026-02-06 21:34:34 +01:00
e5cd76c0ba Publish frontend tests
All checks were successful
Tests / Declarative: Post Actions passed: 14
CI-Multi/pipeline/pr-main This commit looks good
2026-02-06 21:13:24 +01:00
484412b34e Generate junit xml in frontend tests 2026-02-06 21:11:28 +01:00
9409707329 Update ci msgs 2026-02-06 21:08:25 +01:00
aafc74d876 Delete illegal option
All checks were successful
Tests / Declarative: Post Actions passed: 5
CI-Multi/pipeline/pr-main This commit looks good
2026-02-06 20:59:19 +01:00
408411117c Update shell
Some checks failed
Tests / Declarative: Post Actions passed: 5
CI-Multi/pipeline/pr-main There was a failure building this commit
2026-02-06 20:57:55 +01:00
22f19a2ced Update Jenkinsfile.ci
Some checks failed
Tests / Declarative: Post Actions passed: 5
CI-Multi/pipeline/pr-main There was a failure building this commit
2026-02-06 20:54:14 +01:00
ac2174d365 Actualizar Jenkinsfile.ci
All checks were successful
Tests / Declarative: Post Actions passed: 5
CI-Multi/pipeline/pr-main This commit looks good
2026-02-06 20:45:24 +01:00
4ef74b38eb Actualizar Jenkinsfile.ci
All checks were successful
Tests / Declarative: Post Actions passed: 5
CI-Multi/pipeline/pr-main This commit looks good
2026-02-06 20:34:23 +01:00
214aec2e73 Actualizar Jenkinsfile.ci
Some checks failed
Tests / Declarative: Post Actions passed: 5
CI-Multi/pipeline/pr-main There was a failure building this commit
2026-02-06 20:31:19 +01:00
f431f1c5dc Actualizar Jenkinsfile.ci
Some checks failed
Tests / Declarative: Post Actions passed: 5
CI-Multi/pipeline/pr-main There was a failure building this commit
2026-02-06 20:27:14 +01:00
0550e4a431 Update jenkinsfile.ci
All checks were successful
Tests / Declarative: Post Actions No test results found
CI-Multi/pipeline/pr-main This commit looks good
2026-02-06 20:22:25 +01:00
2264b76d9c Merge pull request 'feature/frontend-tests' (#26) from feature/frontend-tests into main
Reviewed-on: #26
Reviewed-by: Jose <husbando_enjoyer@openbokeron+gitea.noreply@geeklab.es>
2026-02-05 20:33:15 +01:00
Abdulee
4ab3ca0642 fix(test): reemplazar tests async fallidos por tests sincronos
All checks were successful
OB/TallerCiCd/pipeline/pr-main This commit looks good
CI-Multi/pipeline/pr-main This commit looks good
2026-02-04 13:48:38 +01:00
Abdulee
beeb58c07a fix(test): configurar jest-dom para tests de componente 2026-02-04 13:36:55 +01:00
Abdulee
5d1f518733 fix(test): eliminar test incorrecto de mayusculas 2026-02-04 13:36:49 +01:00
Abdulee
d2f16e65b2 chore: excluir TESTS.md del repositorio 2026-02-04 13:32:39 +01:00
Abdulee
0fe2569131 ci: ejecutar npm test en pipeline frontend 2026-02-04 13:20:59 +01:00
Abdulee
33a629f47d docs(frontend): añadir documentación de tests 2026-02-04 13:20:29 +01:00
Abdulee
e30ff9b178 test(frontend): añadir tests de componente App 2026-02-04 13:19:30 +01:00
Abdulee
79bfdf78c1 test(frontend): añadir tests de API con mocking 2026-02-04 13:18:36 +01:00
Abdulee
e40ca64536 test(frontend): añadir tests unitarios para prettify() 2026-02-04 13:16:28 +01:00
295accd825 Merge pull request 'Update CI job' (#20) from bugfix/main-11-WorkspaceCleanup into main
Reviewed-on: #20
2026-01-17 22:46:39 +01:00
87b846b4ae Add node
All checks were successful
CI-Multi/pipeline/pr-main This commit looks good
2026-01-17 22:09:31 +01:00
330c2a5364 Update CI job 2026-01-17 12:33:49 +01:00
512c1cea7b Merge pull request 'bugfix/main-16-PreventException' (#18) from bugfix/main-16-PreventException into main
Reviewed-on: #18
2025-12-28 11:35:40 +01:00
5845fed88f Ruff fix 2025-12-28 11:34:30 +01:00
938fd8170c Return error status 2025-12-28 11:34:01 +01:00
17 changed files with 636 additions and 116 deletions

View File

@@ -9,7 +9,7 @@ pipeline {
parameters { parameters {
string( string(
name: 'JENKINS_BASE_URL', name: 'JENKINS_BASE_URL',
defaultValue: 'https://openbokeron.org/jenkins', defaultValue: 'https://openbokeron.uma.es/jenkins',
description: 'Base URL del Jenkins objetivo' description: 'Base URL del Jenkins objetivo'
) )
string( string(
@@ -36,32 +36,6 @@ pipeline {
stages { 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 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 DEPLOY
========================= */ ========================= */

View File

@@ -38,11 +38,12 @@ pipeline {
agent { agent {
docker { docker {
image 'python:3.11-slim' image 'python:3.11-slim'
args '-u root'
} }
} }
environment { environment {
HOME = "${WORKSPACE}"
PIP_CACHE_DIR = "${WORKSPACE}/.cache/pip"
PYTHONDONTWRITEBYTECODE = 1 PYTHONDONTWRITEBYTECODE = 1
} }
@@ -50,12 +51,13 @@ pipeline {
dir('backend') { dir('backend') {
sh ''' sh '''
set -e set -e
mkdir -p "$PIP_CACHE_DIR" "$WORKSPACE/.cache/pytest"
python -m venv .venv python -m venv .venv
. .venv/bin/activate . .venv/bin/activate
pip install --upgrade pip pip install --upgrade pip
pip install -r requirements-dev.txt pip install -r requirements-dev.txt
ruff check app tests ruff check app tests
pytest pytest -o cache_dir="$WORKSPACE/.cache/pytest" --junitxml=pytest.xml
''' '''
} }
} }
@@ -71,23 +73,95 @@ pipeline {
image 'node:20-slim' image 'node:20-slim'
} }
} }
environment {
HOME = "${WORKSPACE}"
NPM_CONFIG_CACHE = "${WORKSPACE}/.cache/npm"
}
steps { steps {
dir('frontend') { dir('frontend') {
sh ''' sh '''
set -e set -e
mkdir -p "$NPM_CONFIG_CACHE"
npm install --no-progress --no-audit --prefer-offline npm install --no-progress --no-audit --prefer-offline
npm run check npm run check
mkdir -p test-results
npm test
npm run build npm run build
''' '''
} }
} }
} }
stage('Cleanup') { }
agent any
steps { post {
cleanWs() 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()
}
} }
} }
} }
}

View File

@@ -1,44 +0,0 @@
{
"builds": [
{
"number": 205,
"status": "success",
"branch": "main",
"commit": "9ac3f91",
"author": "Miau",
"finished_at": "2024-05-04T10:20:00Z",
"duration_seconds": 312
},
{
"number": 204,
"status": "failed",
"branch": "feature/nosetioestoesunmock",
"commit": "75c4ba2",
"author": "Miau",
"finished_at": "2024-05-04T09:50:00Z",
"duration_seconds": 188,
"failed_stage": "tests",
"fun_message": "woops"
},
{
"number": 203,
"status": "failed",
"branch": "main",
"commit": "512ca7e",
"author": "Miau",
"finished_at": "2024-05-04T09:10:00Z",
"duration_seconds": 140,
"failed_stage": "lint",
"fun_message": "Nadie pasa en local el linter"
},
{
"number": 202,
"status": "success",
"branch": "hotfix/tehedichoqueestoesunmock?",
"commit": "c73d8ab",
"author": "Miau",
"finished_at": "2024-05-03T18:30:00Z",
"duration_seconds": 276
}
]
}

View File

@@ -16,8 +16,10 @@
import time import time
from fastapi import FastAPI import requests
from fastapi import FastAPI, status
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.services.builds import build_history from app.services.builds import build_history
from app.services.menu import build_menu from app.services.menu import build_menu
@@ -55,7 +57,7 @@ def health():
"commit": settings.git_commit, "commit": settings.git_commit,
"build": settings.build_number, "build": settings.build_number,
"author": settings.commit_author, "author": settings.commit_author,
"uptime_seconds": uptime() "uptime_seconds": uptime(),
} }
@@ -76,4 +78,10 @@ def price_for_item(item: str):
@app.get("/builds") @app.get("/builds")
def builds(): def builds():
return build_history() try:
return build_history()
except (requests.RequestException, ValueError):
return JSONResponse(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
content={"builds": [], "error": "jenkins_unavailable"},
)

View File

@@ -15,69 +15,116 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
import base64 import base64
import json import html
from pathlib import Path import re
from typing import Dict, List from typing import Dict, List, Tuple
import requests import requests
from app.settings import settings from app.settings import settings
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
def _load_json(filename: str) -> Dict:
path = DATA_DIR / filename
with open(path, encoding="utf-8") as file:
return json.load(file)
def _sort_builds(builds: List[Dict]) -> List[Dict]: def _sort_builds(builds: List[Dict]) -> List[Dict]:
return sorted(builds, key=lambda build: build.get("number", 0), reverse=True) return sorted(builds, key=lambda build: build.get("number", 0), reverse=True)
def normalize_build(build: Dict) -> Dict:
changes = build.get("changeSets", [])
commits = []
for cs in changes: def _extract_commits(build: Dict) -> List[Dict]:
commits = []
for cs in build.get("changeSets", []):
for item in cs.get("items", []): for item in cs.get("items", []):
commits.append({ commits.append(
"commit": item.get("commitId", "")[:7], {
"message": item.get("msg", ""), "commit": item.get("commitId", "")[:7],
"author": item.get("author", {}).get("fullName", "unknown"), "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 { return {
"number": build.get("number"), "number": build.get("number"),
"status": (build.get("result") or "RUNNING").lower(), "status": status,
"finished_at": build.get("timestamp"), "finished_at": build.get("timestamp"),
"duration_seconds": build.get("duration", 0) // 1000, "duration_seconds": build.get("duration", 0) // 1000,
"url": build.get("url"), "url": build.get("url"),
"commits": commits, "commits": commits,
"trigger": trigger,
"trigger_label": trigger_label,
"trigger_url": trigger_url,
"trigger_author": trigger_author,
} }
def _auth_header() -> Dict[str, str]: def _auth_header() -> Dict[str, str]:
if not settings.jenkins_user or not settings.jenkins_token:
return {}
token = f"{settings.jenkins_user}:{settings.jenkins_token}" token = f"{settings.jenkins_user}:{settings.jenkins_token}"
encoded = base64.b64encode(token.encode()).decode() encoded = base64.b64encode(token.encode()).decode()
return {"Authorization": f"Basic {encoded}"} return {"Authorization": f"Basic {encoded}"}
def fetch_builds(limit: int = 5) -> List[Dict]: def fetch_builds(limit: int = 5) -> List[Dict]:
if not settings.jenkins_job_name:
raise ValueError("JENKINS_JOB_NAME not configured")
url = ( url = (
f"{settings.jenkins_base_url}/job/{settings.jenkins_job_name}/api/json" f"{settings.jenkins_base_url}/job/{settings.jenkins_job_name}/api/json"
"?tree=builds[number,url,result,timestamp,duration," "?tree=builds[number,url,result,timestamp,duration,building,"
"changesets[items[commitId,msg,author[fullName]]]]" "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() resp.raise_for_status()
builds = resp.json().get("builds", []) builds = resp.json().get("builds", [])
return builds[:limit] return _sort_builds(builds)[:limit]
def build_history() -> Dict: def build_history() -> Dict:
"""Return Jenkins build history data.""" """Return Jenkins build history data."""
builds = fetch_builds() builds = fetch_builds()
return { return {"builds": [normalize_build(b) for b in builds]}
"builds": [normalize_build(b) for b in builds]
}

View File

@@ -54,6 +54,7 @@ def _pick_mains(count: int = ITEMS_PER_SECTION) -> List[str]:
random.shuffle(mains) random.shuffle(mains)
return mains return mains
def _pick_garnish() -> List[str]: def _pick_garnish() -> List[str]:
garnish_options = MENU_SOURCE["mains"]["garnish"] garnish_options = MENU_SOURCE["mains"]["garnish"]
@@ -69,6 +70,7 @@ def _build_alternative() -> Dict:
"note": alternative.get("note", ""), "note": alternative.get("note", ""),
} }
def build_menu(items_per_section: int = ITEMS_PER_SECTION) -> Dict: def build_menu(items_per_section: int = ITEMS_PER_SECTION) -> Dict:
today = datetime.now() today = datetime.now()

View File

@@ -24,10 +24,7 @@ class RuntimeConfig:
git_commit: str = os.getenv("GIT_COMMIT", "local") git_commit: str = os.getenv("GIT_COMMIT", "local")
build_number: str = os.getenv("BUILD_NUMBER", "-") build_number: str = os.getenv("BUILD_NUMBER", "-")
commit_author: str = os.getenv("COMMIT_AUTHOR", "local") commit_author: str = os.getenv("COMMIT_AUTHOR", "local")
jenkins_base_url: str = os.getenv( jenkins_base_url: str = os.getenv("JENKINS_BASE_URL", "http://localhost:8080").rstrip("/")
"JENKINS_BASE_URL",
"http://localhost:8080"
).rstrip("/")
jenkins_job_name: str = os.getenv("JENKINS_JOB_NAME", "") jenkins_job_name: str = os.getenv("JENKINS_JOB_NAME", "")
jenkins_user: str = os.getenv("JENKINS_USER", "") jenkins_user: str = os.getenv("JENKINS_USER", "")
jenkins_token: str = os.getenv("JENKINS_TOKEN", "") jenkins_token: str = os.getenv("JENKINS_TOKEN", "")

View File

@@ -14,6 +14,7 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
import requests
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.main import app from app.main import app
@@ -60,6 +61,15 @@ def test_build_history(monkeypatch):
"timestamp": 1719992400000, "timestamp": 1719992400000,
"duration": 75000, "duration": 75000,
"url": "http://jenkins.local/job/demo/205", "url": "http://jenkins.local/job/demo/205",
"actions": [
{
"causes": [
{
"shortDescription": "Triggered by Merge pull request #37",
}
]
}
],
"changeSets": [ "changeSets": [
{ {
"items": [ "items": [
@@ -78,6 +88,7 @@ def test_build_history(monkeypatch):
"timestamp": 1719988800000, "timestamp": 1719988800000,
"duration": 1500, "duration": 1500,
"url": "http://jenkins.local/job/demo/204", "url": "http://jenkins.local/job/demo/204",
"actions": [],
"changeSets": [], "changeSets": [],
}, },
] ]
@@ -100,9 +111,30 @@ def test_build_history(monkeypatch):
assert first["commits"] == [ assert first["commits"] == [
{"commit": "9ac3f91", "message": "Anade la API de Jenkins", "author": "Dev One"} {"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] second = builds[1]
assert second["number"] == 204 assert second["number"] == 204
assert second["status"] == "running" assert second["status"] == "running"
assert second["duration_seconds"] == 1 assert second["duration_seconds"] == 1
assert second["commits"] == [] 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):
def raise_error(limit=5):
raise requests.RequestException("boom")
monkeypatch.setattr("app.services.builds.fetch_builds", raise_error)
response = client.get("/builds")
assert response.status_code == 503
body = response.json()
assert body["builds"] == []
assert body["error"] == "jenkins_unavailable"

1
frontend/.gitignore vendored
View File

@@ -6,3 +6,4 @@ dist
*.log *.log
.svelte-kit .svelte-kit
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
TESTS.md

View File

@@ -8,7 +8,7 @@
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-check", "check": "svelte-check",
"test": "vitest" "test": "vitest run --reporter=default --reporter=junit --outputFile=./test-results/junit.xml"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^3.0.1", "@sveltejs/vite-plugin-svelte": "^3.0.1",

View File

@@ -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={`status-dot ${build.status}`}></span>
<span class="summary-title">#{build.number}</span> <span class="summary-title">#{build.number}</span>
{#if build.commits.length === 0} {#if build.trigger_label || build.trigger}
<span class="muted">Ejecución manual</span> <div class="summary-trigger">
{:else} {#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"> <span class="summary-commit">
{build.commits[0].commit} · {build.commits[0].author} {build.commits[0].commit} · {build.commits[0].author}
</span> </span>
{:else}
<span class="muted">Ejecución manual</span>
{/if} {/if}
</div> </div>
@@ -394,7 +407,36 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</summary> </summary>
<div class="history-body"> <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} {#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"> <p class="history-row">
<span>Mensaje</span> <span>Mensaje</span>
<span>{build.commits[0].message}</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'} {#if build.status === 'failure'}
<p class="history-message danger">Build fallida</p> <p class="history-message danger">Build fallida</p>
{:else if build.status === 'running'}
<p class="history-message running">En curso</p>
{:else} {:else}
<p class="history-message success">Correcto</p> <p class="history-message success">Correcto</p>
{/if} {/if}

127
frontend/src/App.test.js Normal file
View 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();
});
});
});

View 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);
});
});
});

View 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';

View File

@@ -697,6 +697,25 @@ li {
font-size: 0.95rem; 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 { .summary-meta {
display: flex; display: flex;
gap: 0.6rem; gap: 0.6rem;
@@ -723,6 +742,11 @@ li {
box-shadow: 0 0 8px rgba(248, 113, 113, 0.5); 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 { .history-body {
padding: 0.75rem 0.9rem 0.9rem; padding: 0.75rem 0.9rem 0.9rem;
border-top: 1px solid rgba(255, 255, 255, 0.05); border-top: 1px solid rgba(255, 255, 255, 0.05);
@@ -743,6 +767,21 @@ li {
color: #cbd5f5; 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 { .history-message {
margin: 0.2rem 0 0; margin: 0.2rem 0 0;
background: rgba(248, 113, 113, 0.12); background: rgba(248, 113, 113, 0.12);
@@ -758,6 +797,12 @@ li {
color: #bbf7d0; 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] { .history-item[open] {
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.28); box-shadow: 0 10px 30px rgba(0, 0, 0, 0.28);
} }
@@ -770,6 +815,10 @@ li {
border-color: rgba(248, 113, 113, 0.35); border-color: rgba(248, 113, 113, 0.35);
} }
.history-item.running {
border-color: rgba(250, 204, 21, 0.35);
}
.chip.danger { .chip.danger {
background: rgba(248, 113, 113, 0.2); background: rgba(248, 113, 113, 0.2);
color: #fecdd3; color: #fecdd3;
@@ -820,3 +869,15 @@ li {
font-size: 0.8rem; font-size: 0.8rem;
opacity: 0.85; 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;
}

View 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('');
});
});

View File

@@ -14,6 +14,7 @@ export default defineConfig(({ mode }) => {
test: { test: {
environment: 'jsdom', environment: 'jsdom',
globals: true, globals: true,
setupFiles: ['./src/setupTests.js'],
}, },
server: { server: {
port: 5173, port: 5173,