Skip to content

Commit 167f06b

Browse files
feat: add external test runs list and detail views
Agent-Logs-Url: https://github.com/bg-playground/BGSTM/sessions/294d8d86-ccd0-403f-8efe-8eea70b4325f Co-authored-by: bg-playground <259109604+bg-playground@users.noreply.github.com>
1 parent 006316b commit 167f06b

11 files changed

Lines changed: 749 additions & 6 deletions

File tree

backend/app/api/external_results.py

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
import uuid as _uuid_module
1919
from uuid import UUID
2020

21-
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
21+
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
2222
from sqlalchemy.ext.asyncio import AsyncSession
2323
from streaming_form_data import StreamingFormDataParser
2424
from streaming_form_data.targets import FileTarget, ValueTarget
@@ -30,18 +30,26 @@
3030
from app.config import settings
3131
from app.crud.audit_log import write_audit
3232
from app.crud.external_case_artifacts import create_artifact
33-
from app.crud.external_case_results import create_case_result, get_case_result, update_case_result
34-
from app.crud.external_results import create_session, finish_session_db, get_session
33+
from app.crud.external_case_results import (
34+
create_case_result,
35+
get_case_result,
36+
list_case_results_for_session,
37+
update_case_result,
38+
)
39+
from app.crud.external_results import create_session, finish_session_db, get_session, list_sessions
3540
from app.db.session import get_db
3641
from app.models.external_case_artifact import ArtifactKind
3742
from app.models.runner_token import RunnerToken
3843
from app.schemas.external_results import (
3944
ArtifactResponse,
4045
CaseResultCreate,
46+
CaseResultListResponse,
4147
CaseResultResponse,
4248
CaseResultUpdate,
49+
RunStatus,
4350
SessionCreate,
4451
SessionFinish,
52+
SessionListResponse,
4553
SessionResponse,
4654
)
4755
from app.storage import get_storage
@@ -103,8 +111,9 @@ def _session_to_response(session) -> SessionResponse:
103111
project_id=session.project_id,
104112
git_sha=session.git_sha,
105113
git_branch=session.git_branch,
106-
ci_url=session.ci_url,
114+
ci_url=str(session.ci_url) if session.ci_url else None,
107115
metadata=session.run_metadata or {},
116+
summary=session.summary or {},
108117
)
109118

110119

@@ -248,6 +257,28 @@ async def get_external_session(
248257
return _session_to_response(session)
249258

250259

260+
@router.get(
261+
"/external-results/sessions",
262+
response_model=SessionListResponse,
263+
)
264+
async def list_external_sessions(
265+
project_id: UUID | None = Query(None),
266+
status: RunStatus | None = Query(None),
267+
skip: int = Query(0, ge=0),
268+
limit: int = Query(25, ge=1, le=100),
269+
db: AsyncSession = Depends(get_db),
270+
_auth=Depends(get_runner_or_user_auth),
271+
) -> SessionListResponse:
272+
"""Return a paginated list of sessions, newest first."""
273+
sessions, total = await list_sessions(db, project_id=project_id, status=status, skip=skip, limit=limit)
274+
return SessionListResponse(
275+
sessions=[_session_to_response(s) for s in sessions],
276+
total=total,
277+
skip=skip,
278+
limit=limit,
279+
)
280+
281+
251282
# ---------------------------------------------------------------------------
252283
# POST /external-results/case — create a case result
253284
# ---------------------------------------------------------------------------
@@ -416,6 +447,33 @@ async def get_external_case_result(
416447
return _case_result_to_response(case_result)
417448

418449

450+
@router.get(
451+
"/external-results/session/{session_id}/cases",
452+
response_model=CaseResultListResponse,
453+
)
454+
async def list_external_session_cases(
455+
session_id: UUID,
456+
skip: int = Query(0, ge=0),
457+
limit: int = Query(200, ge=1, le=500),
458+
db: AsyncSession = Depends(get_db),
459+
_auth=Depends(get_runner_or_user_auth),
460+
) -> CaseResultListResponse:
461+
"""Return all case results for a given session."""
462+
session = await get_session(db, session_id)
463+
if session is None:
464+
raise HTTPException(
465+
status_code=status.HTTP_404_NOT_FOUND,
466+
detail={"code": "session.not_found", "message": f"Session {session_id} does not exist.", "details": None},
467+
)
468+
cases, total = await list_case_results_for_session(db, session_id=session_id, skip=skip, limit=limit)
469+
return CaseResultListResponse(
470+
cases=[_case_result_to_response(c) for c in cases],
471+
total=total,
472+
skip=skip,
473+
limit=limit,
474+
)
475+
476+
419477
# ---------------------------------------------------------------------------
420478
# POST /external-results/artifact — upload an artifact
421479
# ---------------------------------------------------------------------------

backend/app/crud/external_case_results.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from typing import Any
55
from uuid import UUID
66

7-
from sqlalchemy import select
7+
from sqlalchemy import func, select
88
from sqlalchemy.dialects.postgresql import insert as postgresql_insert
99
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
1010
from sqlalchemy.ext.asyncio import AsyncSession
@@ -347,3 +347,26 @@ async def get_case_result(
347347

348348
case_result.requirement_ids = await _get_requirement_ids_for_test_case(db, test_case_id=case_result.test_case_id)
349349
return case_result
350+
351+
352+
async def list_case_results_for_session(
353+
db: AsyncSession,
354+
*,
355+
session_id: UUID,
356+
skip: int = 0,
357+
limit: int = 200,
358+
) -> tuple[list[ExternalCaseResult], int]:
359+
"""Return all case results for a given session, ordered by created_at asc."""
360+
stmt = (
361+
select(ExternalCaseResult)
362+
.where(ExternalCaseResult.session_id == session_id)
363+
.order_by(ExternalCaseResult.created_at.asc())
364+
.offset(skip)
365+
.limit(limit)
366+
)
367+
count_stmt = select(func.count()).select_from(ExternalCaseResult).where(ExternalCaseResult.session_id == session_id)
368+
total = (await db.execute(count_stmt)).scalar_one()
369+
rows = (await db.execute(stmt)).scalars().all()
370+
for row in rows:
371+
row.requirement_ids = await _get_requirement_ids_for_test_case(db, test_case_id=row.test_case_id)
372+
return list(rows), total

backend/app/crud/external_results.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from datetime import datetime, timedelta, timezone
44
from uuid import UUID
55

6-
from sqlalchemy import select
6+
from sqlalchemy import func, select
77
from sqlalchemy.ext.asyncio import AsyncSession
88

99
from app.models.external_results import ExternalRunSession, RunStatus
@@ -137,3 +137,26 @@ async def get_session(
137137
"""Return a single ExternalRunSession by primary key, or None."""
138138
result = await db.execute(select(ExternalRunSession).where(ExternalRunSession.id == session_id))
139139
return result.scalar_one_or_none()
140+
141+
142+
async def list_sessions(
143+
db: AsyncSession,
144+
*,
145+
project_id: UUID | None = None,
146+
status: RunStatus | None = None,
147+
skip: int = 0,
148+
limit: int = 25,
149+
) -> tuple[list[ExternalRunSession], int]:
150+
"""Return a paginated list of sessions and total count."""
151+
stmt = select(ExternalRunSession).order_by(ExternalRunSession.started_at.desc())
152+
count_stmt = select(func.count()).select_from(ExternalRunSession)
153+
if project_id is not None:
154+
stmt = stmt.where(ExternalRunSession.project_id == project_id)
155+
count_stmt = count_stmt.where(ExternalRunSession.project_id == project_id)
156+
if status is not None:
157+
stmt = stmt.where(ExternalRunSession.status == status)
158+
count_stmt = count_stmt.where(ExternalRunSession.status == status)
159+
stmt = stmt.offset(skip).limit(limit)
160+
total = (await db.execute(count_stmt)).scalar_one()
161+
rows = (await db.execute(stmt)).scalars().all()
162+
return list(rows), total

backend/app/schemas/external_results.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ class SessionResponse(BaseModel):
9595
git_branch: str | None = None
9696
ci_url: HttpUrl | None = None
9797
metadata: dict[str, Any] = Field(default_factory=dict)
98+
summary: dict[str, Any] | None = None
9899

99100

100101
_TERMINAL_STATUSES = {RunStatus.passed, RunStatus.failed, RunStatus.aborted}
@@ -197,6 +198,24 @@ class CaseResultResponse(BaseModel):
197198
)
198199

199200

201+
class SessionListResponse(BaseModel):
202+
"""Response for GET /external-results/sessions."""
203+
204+
sessions: list[SessionResponse]
205+
total: int
206+
skip: int
207+
limit: int
208+
209+
210+
class CaseResultListResponse(BaseModel):
211+
"""Response for GET /external-results/session/{id}/cases."""
212+
213+
cases: list[CaseResultResponse]
214+
total: int
215+
skip: int
216+
limit: int
217+
218+
200219
class CaseResultUpdate(BaseModel):
201220
"""Payload for ``PATCH /api/v1/external-results/case/{id}``."""
202221

backend/tests/integration/test_external_results_session.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,55 @@ def test_create_without_runner_defaults_to_bgstm_playwright_core(self, db_sessio
174174
data = resp.json()
175175
assert data["runner"].startswith("@bgstm/playwright-core@")
176176

177+
def test_list_sessions_returns_paginated_and_filtered_data(self, db_session, write_token, project_id):
178+
_model, plaintext = write_token
179+
headers = _auth_header(plaintext)
180+
181+
with TestClient(app) as client:
182+
first_payload = dict(_session_payload(project_id), ci_url=f"https://ci.example.com/runs/{uuid.uuid4()}")
183+
second_payload = dict(
184+
_session_payload(project_id),
185+
git_branch="release",
186+
git_sha="def456",
187+
ci_url=f"https://ci.example.com/runs/{uuid.uuid4()}",
188+
)
189+
190+
first_resp = client.post("/api/v1/external-results/session", json=first_payload, headers=headers)
191+
second_resp = client.post("/api/v1/external-results/session", json=second_payload, headers=headers)
192+
assert first_resp.status_code == 201, first_resp.text
193+
assert second_resp.status_code == 201, second_resp.text
194+
195+
first_id = first_resp.json()["id"]
196+
second_id = second_resp.json()["id"]
197+
198+
finish_resp = client.patch(
199+
f"/api/v1/external-results/session/{first_id}",
200+
json={"status": "passed", "summary": {"total": 7, "passed": 7}},
201+
headers=headers,
202+
)
203+
assert finish_resp.status_code == 200, finish_resp.text
204+
205+
list_resp = client.get("/api/v1/external-results/sessions?skip=0&limit=25", headers=headers)
206+
assert list_resp.status_code == 200, list_resp.text
207+
list_data = list_resp.json()
208+
assert list_data["total"] == 2
209+
assert list_data["skip"] == 0
210+
assert list_data["limit"] == 25
211+
returned_ids = [s["id"] for s in list_data["sessions"]]
212+
assert first_id in returned_ids
213+
assert second_id in returned_ids
214+
finished_row = next(s for s in list_data["sessions"] if s["id"] == first_id)
215+
assert finished_row["summary"] == {"total": 7, "passed": 7}
216+
217+
filtered_resp = client.get(
218+
"/api/v1/external-results/sessions?status=passed&skip=0&limit=25",
219+
headers=headers,
220+
)
221+
assert filtered_resp.status_code == 200, filtered_resp.text
222+
filtered_data = filtered_resp.json()
223+
assert filtered_data["total"] == 1
224+
assert [s["id"] for s in filtered_data["sessions"]] == [first_id]
225+
177226

178227
# ---------------------------------------------------------------------------
179228
# Auth: 401 without credentials

backend/tests/test_external_results_case.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,3 +538,58 @@ async def test_auth_requirements(self, db_session, write_token, read_token):
538538
assert get_with_user.status_code == 200
539539
assert get_with_runner.status_code == 200
540540
assert get_unknown.status_code == 404
541+
542+
@pytest.mark.asyncio
543+
async def test_list_session_cases_returns_paginated_rows_and_404_for_unknown_session(
544+
self, db_session, write_token, read_token
545+
):
546+
_write_model, write_plaintext = write_token
547+
_read_model, read_plaintext = read_token
548+
549+
with TestClient(app) as client:
550+
session_id = _create_session(client, write_plaintext)
551+
first = client.post(
552+
"/api/v1/external-results/case",
553+
json={
554+
"session_id": session_id,
555+
"external_id": f"case-a-{uuid.uuid4()}",
556+
"title": "case-a",
557+
"outcome": "passed",
558+
"duration_ms": 12,
559+
"requirement_ids": [],
560+
},
561+
headers=_auth_header(write_plaintext),
562+
)
563+
second = client.post(
564+
"/api/v1/external-results/case",
565+
json={
566+
"session_id": session_id,
567+
"external_id": f"case-b-{uuid.uuid4()}",
568+
"title": "case-b",
569+
"outcome": "failed",
570+
"duration_ms": 34,
571+
"error_message": "assertion failed",
572+
"requirement_ids": [],
573+
},
574+
headers=_auth_header(write_plaintext),
575+
)
576+
assert first.status_code == 201, first.text
577+
assert second.status_code == 201, second.text
578+
579+
list_resp = client.get(
580+
f"/api/v1/external-results/session/{session_id}/cases?skip=0&limit=200",
581+
headers=_auth_header(read_plaintext),
582+
)
583+
assert list_resp.status_code == 200, list_resp.text
584+
list_data = list_resp.json()
585+
assert list_data["total"] == 2
586+
assert list_data["skip"] == 0
587+
assert list_data["limit"] == 200
588+
assert [case["id"] for case in list_data["cases"]] == [first.json()["id"], second.json()["id"]]
589+
590+
missing_resp = client.get(
591+
f"/api/v1/external-results/session/{uuid.uuid4()}/cases",
592+
headers=_auth_header(read_plaintext),
593+
)
594+
assert missing_resp.status_code == 404
595+
assert missing_resp.json()["detail"]["code"] == "session.not_found"

frontend/src/App.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { SuggestionDashboard } from './pages/SuggestionDashboard';
1010
import { RequirementsPage } from './pages/RequirementsPage';
1111
import { TestCasesPage } from './pages/TestCasesPage';
1212
import { ManualLinksPage } from './pages/ManualLinksPage';
13+
import { TestRunsPage } from './pages/TestRunsPage';
14+
import TestRunDetailPage from './pages/TestRunDetailPage';
1315
import TraceabilityMatrixPage from './pages/TraceabilityMatrixPage';
1416
import MetricsDashboardPage from './pages/MetricsDashboardPage';
1517
import { AuditLogPage } from './pages/AuditLogPage';
@@ -38,6 +40,8 @@ function App() {
3840
<Route path="/" element={<SuggestionDashboard />} />
3941
<Route path="/requirements" element={<RequirementsPage />} />
4042
<Route path="/test-cases" element={<TestCasesPage />} />
43+
<Route path="/runs" element={<TestRunsPage />} />
44+
<Route path="/runs/:sessionId" element={<TestRunDetailPage />} />
4145
<Route path="/links" element={<ManualLinksPage />} />
4246
<Route path="/traceability" element={<TraceabilityMatrixPage />} />
4347
<Route path="/metrics" element={<MetricsDashboardPage />} />

0 commit comments

Comments
 (0)