Skip to content

Commit 2384242

Browse files
feat: External Results API v1 spec — schemas, docs, nav, tests (BGSTM#299)
Agent-Logs-Url: https://github.com/bg-playground/BGSTM/sessions/272b701a-fa8a-4a0c-82b8-acd89f93a16b Co-authored-by: bg-playground <259109604+bg-playground@users.noreply.github.com>
1 parent 4ae26d4 commit 2384242

5 files changed

Lines changed: 1025 additions & 0 deletions

File tree

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,14 @@ npx playwright test --project=chromium # Chromium only (default in CI)
279279
280280
For full details, see the [E2E Test README](frontend/tests/e2e/README.md).
281281

282+
## 📐 API Contracts & Specs
283+
284+
| Spec | Status | Tracking |
285+
|---|---|---|
286+
| [External Results v1](docs/specs/external_results_v1.md) | Draft | [BGSTM#299](https://github.com/bg-playground/BGSTM/issues/299) |
287+
288+
---
289+
282290
## Related projects
283291

284292
- **[bg-playground/bgstm-playwright-frameworks](https://github.com/bg-playground/bgstm-playwright-frameworks)** — Domain-specific Playwright automation frameworks with native BGSTM traceability. Apache-2.0. Public.
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
"""Pydantic schemas for the External Results API (v1).
2+
3+
This module defines the request/response shapes for external test runners
4+
reporting execution results into BGSTM. It is a types-only module — no
5+
FastAPI imports, no database access.
6+
7+
Canonical spec: docs/specs/external_results_v1.md
8+
Tracking: BGSTM#291 (parent epic), BGSTM#299 (this spec)
9+
"""
10+
11+
from __future__ import annotations
12+
13+
from datetime import datetime
14+
from enum import Enum
15+
from typing import Any
16+
from uuid import UUID
17+
18+
from pydantic import BaseModel, ConfigDict, Field, HttpUrl, model_validator
19+
20+
# ---------------------------------------------------------------------------
21+
# Enumerations
22+
# ---------------------------------------------------------------------------
23+
24+
25+
class RunStatus(str, Enum):
26+
"""Lifecycle status for an external test session."""
27+
28+
started = "started"
29+
passed = "passed"
30+
failed = "failed"
31+
skipped = "skipped"
32+
aborted = "aborted"
33+
34+
35+
class CaseOutcome(str, Enum):
36+
"""Outcome for an individual test-case result."""
37+
38+
passed = "passed"
39+
failed = "failed"
40+
skipped = "skipped"
41+
flaky = "flaky"
42+
43+
44+
class ArtifactKind(str, Enum):
45+
"""Type of binary artifact attached to a case result."""
46+
47+
screenshot = "screenshot"
48+
trace = "trace"
49+
video = "video"
50+
log = "log"
51+
other = "other"
52+
53+
54+
# ---------------------------------------------------------------------------
55+
# Session models
56+
# ---------------------------------------------------------------------------
57+
58+
_SESSION_EXAMPLE: dict[str, Any] = {
59+
"runner": "@bgstm/playwright-core@0.1.0",
60+
"project_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
61+
"git_sha": "abc123def456",
62+
"git_branch": "main",
63+
"ci_url": "https://github.com/org/repo/actions/runs/123",
64+
"metadata": {"os": "ubuntu-22.04", "node": "20.11.0"},
65+
}
66+
67+
68+
class SessionCreate(BaseModel):
69+
"""Payload for ``POST /api/v1/external-results/session``."""
70+
71+
model_config = ConfigDict(
72+
json_schema_extra={"example": _SESSION_EXAMPLE},
73+
)
74+
75+
runner: str = Field(..., description="Identifier of the test runner (name + version).")
76+
project_id: UUID = Field(..., description="BGSTM project this session belongs to.")
77+
git_sha: str | None = Field(None, description="Full or short commit SHA being tested.")
78+
git_branch: str | None = Field(None, description="Branch name under test.")
79+
ci_url: HttpUrl | None = Field(None, description="URL of the CI job that triggered this run.")
80+
metadata: dict[str, Any] = Field(default_factory=dict, description="Arbitrary key/value runner metadata.")
81+
82+
83+
class SessionResponse(BaseModel):
84+
"""Response body for session endpoints."""
85+
86+
model_config = ConfigDict(from_attributes=True)
87+
88+
id: UUID
89+
status: RunStatus
90+
started_at: datetime
91+
finished_at: datetime | None = None
92+
runner: str
93+
project_id: UUID
94+
git_sha: str | None = None
95+
git_branch: str | None = None
96+
ci_url: HttpUrl | None = None
97+
metadata: dict[str, Any] = Field(default_factory=dict)
98+
99+
100+
_TERMINAL_STATUSES = {RunStatus.passed, RunStatus.failed, RunStatus.aborted}
101+
102+
103+
class SessionFinish(BaseModel):
104+
"""Payload for ``PATCH /api/v1/external-results/session/{session_id}``."""
105+
106+
status: RunStatus = Field(..., description="Terminal status for the session.")
107+
summary: dict[str, Any] = Field(
108+
default_factory=dict,
109+
description="Aggregate counters, e.g. ``{total: 42, failed: 2}``.",
110+
examples=[{"total": 42, "failed": 2}],
111+
)
112+
113+
@model_validator(mode="after")
114+
def _validate_terminal_status(self) -> SessionFinish:
115+
if self.status not in _TERMINAL_STATUSES:
116+
raise ValueError(
117+
f"SessionFinish.status must be one of "
118+
f"{[s.value for s in _TERMINAL_STATUSES]}, got '{self.status.value}'."
119+
)
120+
return self
121+
122+
123+
# ---------------------------------------------------------------------------
124+
# Case result models
125+
# ---------------------------------------------------------------------------
126+
127+
128+
class CaseResultCreate(BaseModel):
129+
"""Payload for ``POST /api/v1/external-results/case``."""
130+
131+
session_id: UUID = Field(..., description="Session this result belongs to.")
132+
test_case_id: UUID | None = Field(None, description="BGSTM test-case UUID, if known.")
133+
external_id: str | None = Field(
134+
None,
135+
description="Runner-assigned identifier (e.g. full test title). "
136+
"Duplicate external_id within the same session collapses to the same row.",
137+
)
138+
title: str = Field(..., description="Human-readable test title.")
139+
outcome: CaseOutcome
140+
duration_ms: int = Field(..., ge=0, description="Wall-clock duration in milliseconds.")
141+
error_message: str | None = Field(None, description="First error line or assertion message.")
142+
requirement_ids: list[UUID] = Field(
143+
default_factory=list,
144+
description="Requirement UUIDs to link; duplicate insertion is a no-op.",
145+
)
146+
147+
@model_validator(mode="after")
148+
def _require_at_least_one_id(self) -> CaseResultCreate:
149+
if self.test_case_id is None and self.external_id is None:
150+
raise ValueError("At least one of 'test_case_id' or 'external_id' must be provided.")
151+
return self
152+
153+
154+
class CaseResultResponse(BaseModel):
155+
"""Response body for case-result endpoints."""
156+
157+
model_config = ConfigDict(from_attributes=True)
158+
159+
id: UUID
160+
session_id: UUID
161+
test_case_id: UUID | None = None
162+
external_id: str | None = None
163+
title: str
164+
outcome: CaseOutcome
165+
duration_ms: int = Field(..., ge=0)
166+
error_message: str | None = None
167+
requirement_ids: list[UUID] = Field(default_factory=list)
168+
created_at: datetime
169+
auto_registered: bool = Field(
170+
False,
171+
description="True when BGSTM created a new test-case record from external_id.",
172+
)
173+
174+
175+
class CaseResultUpdate(BaseModel):
176+
"""Payload for ``PATCH /api/v1/external-results/case/{id}``."""
177+
178+
outcome: CaseOutcome | None = None
179+
duration_ms: int | None = Field(None, ge=0)
180+
error_message: str | None = None
181+
182+
183+
# ---------------------------------------------------------------------------
184+
# Artifact models
185+
# ---------------------------------------------------------------------------
186+
187+
188+
class ArtifactCreate(BaseModel):
189+
"""Payload for ``POST /api/v1/external-results/artifact`` (multipart metadata part)."""
190+
191+
case_result_id: UUID = Field(..., description="Case result this artifact belongs to.")
192+
kind: ArtifactKind
193+
filename: str = Field(..., description="Original filename, including extension.")
194+
content_type: str = Field(..., description="MIME type, e.g. ``image/png``.")
195+
size_bytes: int = Field(..., ge=0, description="Byte length of the artifact body.")
196+
197+
198+
class ArtifactResponse(ArtifactCreate):
199+
"""Response body for artifact endpoints."""
200+
201+
model_config = ConfigDict(from_attributes=True)
202+
203+
id: UUID
204+
url: HttpUrl = Field(..., description="Presigned or permanent download URL.")
205+
created_at: datetime
206+
207+
208+
# ---------------------------------------------------------------------------
209+
# Error model
210+
# ---------------------------------------------------------------------------
211+
212+
213+
class ErrorResponse(BaseModel):
214+
"""Unified error envelope returned by all External Results endpoints."""
215+
216+
code: str = Field(..., description="Machine-readable error code, e.g. ``runner_token.invalid``.")
217+
message: str = Field(..., description="Human-readable error description.")
218+
details: dict[str, Any] | None = Field(None, description="Optional structured detail map.")

0 commit comments

Comments
 (0)