1use std::collections::{BTreeMap, BTreeSet};
8
9use supercov_contracts::{
10 AttributionPrecision, FrontendDeclarationError, FrontendRunDeclaration,
11 FrontendRunnerDeclaration, validate_frontend_run_declaration,
12};
13
14use crate::coverage_report::{
15 CoverageReport, CoverageReportRequest, RawTestResult, ReportError, analyze_coverage_results,
16};
17
18#[derive(Debug)]
19pub enum FrontendProtocolError {
20 Declaration(FrontendDeclarationError),
21 InvalidManifestLimitation,
22 DuplicateManifestLimitation(String),
23 StructuralLimitationMismatch {
24 declared: Vec<String>,
25 manifest: Vec<String>,
26 },
27 UndeclaredRunner(String),
28 UnobservedRunner(String),
29 MissingExactIdentity {
30 runner: String,
31 axis: &'static str,
32 },
33 ScopeRunMismatch {
34 expected: String,
35 actual: String,
36 },
37 RetryMismatch {
38 runner: String,
39 result: usize,
40 scope: usize,
41 },
42 InvalidPhaseKind(String),
43 DuplicatePhase(String),
44 UnknownPhaseReference(String),
45 CyclicPhaseReference(String),
46 Analysis(ReportError),
47}
48
49impl std::fmt::Display for FrontendProtocolError {
50 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51 match self {
52 Self::Declaration(error) => write!(formatter, "{error}"),
53 Self::InvalidManifestLimitation => {
54 write!(
55 formatter,
56 "frontend manifest contains a limitation without an ID"
57 )
58 }
59 Self::DuplicateManifestLimitation(id) => {
60 write!(formatter, "duplicate frontend manifest limitation: {id}")
61 }
62 Self::StructuralLimitationMismatch { declared, manifest } => write!(
63 formatter,
64 "frontend structural limitation references differ: declared={declared:?} manifest={manifest:?}"
65 ),
66 Self::UndeclaredRunner(runner) => {
67 write!(
68 formatter,
69 "frontend evidence uses undeclared runner: {runner}"
70 )
71 }
72 Self::UnobservedRunner(runner) => {
73 write!(
74 formatter,
75 "frontend declares an unobserved runner: {runner}"
76 )
77 }
78 Self::MissingExactIdentity { runner, axis } => {
79 write!(
80 formatter,
81 "frontend runner {runner} is missing exact {axis} identity"
82 )
83 }
84 Self::ScopeRunMismatch { expected, actual } => write!(
85 formatter,
86 "frontend evidence run identity differs: expected={expected} actual={actual}"
87 ),
88 Self::RetryMismatch {
89 runner,
90 result,
91 scope,
92 } => write!(
93 formatter,
94 "frontend runner {runner} retry identity differs: result={result} scope={scope}"
95 ),
96 Self::InvalidPhaseKind(kind) => {
97 write!(formatter, "unsupported frontend phase kind: {kind}")
98 }
99 Self::DuplicatePhase(id) => write!(formatter, "duplicate frontend phase ID: {id}"),
100 Self::UnknownPhaseReference(id) => {
101 write!(formatter, "unknown frontend phase reference: {id}")
102 }
103 Self::CyclicPhaseReference(id) => {
104 write!(formatter, "cyclic frontend phase causality at: {id}")
105 }
106 Self::Analysis(error) => write!(formatter, "{error:?}"),
107 }
108 }
109}
110
111impl std::error::Error for FrontendProtocolError {}
112
113impl From<FrontendDeclarationError> for FrontendProtocolError {
114 fn from(error: FrontendDeclarationError) -> Self {
115 Self::Declaration(error)
116 }
117}
118
119fn manifest_limitation_ids(
120 request: &CoverageReportRequest,
121) -> Result<BTreeSet<String>, FrontendProtocolError> {
122 let mut ids = BTreeSet::new();
123 for limitation in &request.manifest.limitations {
124 let id = limitation
125 .get("id")
126 .and_then(serde_json::Value::as_str)
127 .filter(|id| !id.is_empty())
128 .ok_or(FrontendProtocolError::InvalidManifestLimitation)?;
129 if !ids.insert(id.to_owned()) {
130 return Err(FrontendProtocolError::DuplicateManifestLimitation(
131 id.to_owned(),
132 ));
133 }
134 }
135 Ok(ids)
136}
137
138fn present(value: &str) -> bool {
139 !value.trim().is_empty() && !value.chars().any(char::is_control)
140}
141
142fn require_exact_identities(
143 runner: &FrontendRunnerDeclaration,
144 raw: &RawTestResult,
145 run_id: &str,
146 global_phase_ids: &mut BTreeSet<String>,
147) -> Result<(), FrontendProtocolError> {
148 let missing = |axis| FrontendProtocolError::MissingExactIdentity {
149 runner: runner.runner.clone(),
150 axis,
151 };
152 if runner.attribution.test == AttributionPrecision::Exact
153 && !raw.test_id.as_deref().is_some_and(present)
154 {
155 return Err(missing("test"));
156 }
157 if let Some(scope) = &raw.scope {
158 if scope.run_id != run_id {
159 return Err(FrontendProtocolError::ScopeRunMismatch {
160 expected: run_id.to_owned(),
161 actual: scope.run_id.clone(),
162 });
163 }
164 if runner.attribution.worker == AttributionPrecision::Exact && !present(&scope.worker_id) {
165 return Err(missing("worker"));
166 }
167 if runner.attribution.test == AttributionPrecision::Exact
168 && (!present(&scope.test_id) || raw.test_id.as_deref() != Some(scope.test_id.as_str()))
169 {
170 return Err(missing("test"));
171 }
172 if runner.attribution.retry == AttributionPrecision::Exact {
173 let result_retry = raw.retry.ok_or_else(|| missing("retry"))?;
174 if result_retry != scope.retry {
175 return Err(FrontendProtocolError::RetryMismatch {
176 runner: runner.runner.clone(),
177 result: result_retry,
178 scope: scope.retry,
179 });
180 }
181 }
182 } else if runner.attribution.worker == AttributionPrecision::Exact {
183 return Err(missing("worker"));
184 } else if runner.attribution.retry == AttributionPrecision::Exact && raw.retry.is_none() {
185 return Err(missing("retry"));
186 }
187
188 let mut phase_ids = BTreeSet::new();
189 for phase in &raw.phases {
190 if !matches!(
191 phase.kind.as_str(),
192 "setup" | "test" | "action" | "assertion" | "teardown" | "background"
193 ) {
194 return Err(FrontendProtocolError::InvalidPhaseKind(phase.kind.clone()));
195 }
196 if runner.attribution.phase == AttributionPrecision::Exact && !present(&phase.id) {
197 return Err(missing("phase"));
198 }
199 if !phase_ids.insert(phase.id.clone()) {
200 return Err(FrontendProtocolError::DuplicatePhase(phase.id.clone()));
201 }
202 if !global_phase_ids.insert(phase.id.clone()) {
203 return Err(FrontendProtocolError::DuplicatePhase(phase.id.clone()));
204 }
205 }
206 let phase_reference = |id: &str| {
207 if present(id) && phase_ids.contains(id) {
208 Ok(())
209 } else {
210 Err(FrontendProtocolError::UnknownPhaseReference(id.to_owned()))
211 }
212 };
213 for phase in &raw.phases {
214 if let Some(cause) = &phase.caused_by_phase_id {
215 phase_reference(cause)?;
216 }
217 }
218 let causes = raw
219 .phases
220 .iter()
221 .filter_map(|phase| {
222 phase
223 .caused_by_phase_id
224 .as_ref()
225 .map(|cause| (phase.id.as_str(), cause.as_str()))
226 })
227 .collect::<BTreeMap<_, _>>();
228 for start in causes.keys() {
229 let mut visited = BTreeSet::new();
230 let mut current = *start;
231 while let Some(next) = causes.get(current) {
232 if !visited.insert(current) {
233 return Err(FrontendProtocolError::CyclicPhaseReference(
234 (*start).to_owned(),
235 ));
236 }
237 current = next;
238 }
239 }
240 for snapshot in raw.runtime.iter().chain(&raw.browser) {
241 for event in &snapshot.events {
242 if let Some(phase) = &event.phase_id {
243 phase_reference(phase)?;
244 }
245 }
246 }
247 for record in &raw.server {
248 if let Some(phase) = &record.phase_id {
249 phase_reference(phase)?;
250 }
251 }
252 Ok(())
253}
254
255pub fn validate_frontend_report_request(
256 declaration: &FrontendRunDeclaration,
257 request: &CoverageReportRequest,
258) -> Result<(), FrontendProtocolError> {
259 validate_frontend_run_declaration(declaration)?;
260 let manifest = manifest_limitation_ids(request)?;
261 let declared = declaration
262 .structural_limitations
263 .iter()
264 .cloned()
265 .collect::<BTreeSet<_>>();
266 if declared != manifest {
267 return Err(FrontendProtocolError::StructuralLimitationMismatch {
268 declared: declared.into_iter().collect(),
269 manifest: manifest.into_iter().collect(),
270 });
271 }
272
273 let runners = declaration
274 .runners
275 .iter()
276 .map(|runner| (runner.runner.as_str(), runner))
277 .collect::<BTreeMap<_, _>>();
278 let mut observed = BTreeSet::new();
279 let mut phase_ids = BTreeSet::new();
280 for raw in &request.raw_results {
281 let name = raw.provenance.runner.as_str();
282 let runner = runners
283 .get(name)
284 .ok_or_else(|| FrontendProtocolError::UndeclaredRunner(name.to_owned()))?;
285 observed.insert(name);
286 require_exact_identities(runner, raw, &request.run_id, &mut phase_ids)?;
287 }
288 for runner in runners.keys() {
289 if !observed.contains(runner) {
290 return Err(FrontendProtocolError::UnobservedRunner(
291 (*runner).to_owned(),
292 ));
293 }
294 }
295 Ok(())
296}
297
298pub fn analyze_frontend_results(
299 declaration: &FrontendRunDeclaration,
300 request: &CoverageReportRequest,
301) -> Result<CoverageReport, FrontendProtocolError> {
302 validate_frontend_report_request(declaration, request)?;
303 analyze_coverage_results(request).map_err(FrontendProtocolError::Analysis)
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309 use crate::{
310 coverage_analysis::PointKind,
311 coverage_report::{
312 CoverageManifest, CoveragePhase, ExecutionScope, ExitCodeInput, PointMeta,
313 RuntimeEvent, RuntimeSnapshot, TestProvenance,
314 },
315 };
316 use supercov_contracts::{
317 ExecutionModel, FrontendAttribution, FrontendLimitation, FrontendLimitationScope,
318 FrontendRunnerDeclaration, LANGUAGE_FRONTEND_PROTOCOL_VERSION, StructuralSource,
319 };
320
321 fn declaration() -> FrontendRunDeclaration {
322 FrontendRunDeclaration {
323 protocol_version: LANGUAGE_FRONTEND_PROTOCOL_VERSION,
324 frontend_id: "fixture".into(),
325 frontend_version: "fixture-v1".into(),
326 language: "fixture".into(),
327 structural_source: StructuralSource::NativeImport,
328 runners: vec![FrontendRunnerDeclaration {
329 runner: "fixture-runner".into(),
330 execution_model: ExecutionModel::SerialInProcess,
331 attribution: FrontendAttribution {
332 run: AttributionPrecision::Exact,
333 worker: AttributionPrecision::Exact,
334 test: AttributionPrecision::Exact,
335 retry: AttributionPrecision::Exact,
336 phase: AttributionPrecision::Exact,
337 action: AttributionPrecision::Unavailable,
338 assertion: AttributionPrecision::Exact,
339 },
340 limitations: vec![FrontendLimitation {
341 id: "no-action-hook".into(),
342 scopes: vec![FrontendLimitationScope::Action],
343 reason: "The fixture runner has no action lifecycle".into(),
344 }],
345 }],
346 structural_limitations: vec!["dynamic-fixture".into()],
347 }
348 }
349
350 fn request() -> CoverageReportRequest {
351 CoverageReportRequest {
352 run_id: "run".into(),
353 manifest: CoverageManifest {
354 decisions: vec![],
355 points: vec![PointMeta {
356 id: "point".into(),
357 kind: PointKind::Statement,
358 file: "src/example.py".into(),
359 line: 1,
360 column: 1,
361 source: "work()".into(),
362 label: None,
363 }],
364 branches: vec![],
365 limitations: vec![serde_json::json!({
366 "id": "dynamic-fixture",
367 "kind": "dynamic-code",
368 "file": "src/example.py",
369 "line": 2,
370 "column": 1,
371 "source": "eval(source)",
372 "reason": "Runtime source has no stable denominator"
373 })],
374 scope: None,
375 },
376 raw_results: vec![RawTestResult {
377 test_id: Some("test".into()),
378 scope: Some(ExecutionScope {
379 version: 1,
380 run_id: "run".into(),
381 worker_id: "worker".into(),
382 test_id: "test".into(),
383 test_key: "test".into(),
384 retry: 0,
385 attempt_id: "attempt".into(),
386 }),
387 test: "test".into(),
388 test_file: Some("tests/test_example.py".into()),
389 title: Some("test".into()),
390 retry: Some(0),
391 status: Some("passed".into()),
392 expected_status: Some("passed".into()),
393 flaky: false,
394 provenance: TestProvenance {
395 runner: "fixture-runner".into(),
396 kind: "integration".into(),
397 project: None,
398 source: "explicit".into(),
399 },
400 role: "test".into(),
401 phases: vec![CoveragePhase {
402 id: "assertion".into(),
403 kind: "assertion".into(),
404 operation: "assert result".into(),
405 source: Some("tests/test_example.py:1".into()),
406 caused_by_phase_id: None,
407 started_at_ms: 1,
408 ended_at_ms: Some(2),
409 status: Some("passed".into()),
410 error: None,
411 }],
412 runtime: vec![RuntimeSnapshot {
413 decisions: vec![],
414 hits: vec!["point".into()],
415 events: vec![RuntimeEvent {
416 event_type: "hit".into(),
417 id: "point".into(),
418 vector: None,
419 timestamp_ms: 1,
420 phase_id: Some("assertion".into()),
421 environment: "fixture".into(),
422 }],
423 }],
424 browser: vec![],
425 server: vec![],
426 }],
427 generated_at: "2026-08-25T00:00:00.000Z".into(),
428 coverage_model: None,
429 integrity: None,
430 test_exit_code: ExitCodeInput::Present(Some(0)),
431 }
432 }
433
434 #[test]
435 fn validates_a_declared_frontend_before_shared_analysis() {
436 let report = analyze_frontend_results(&declaration(), &request()).unwrap();
437 assert!(report.execution.unwrap().valid);
438 assert_eq!(report.view.summary.statements.covered, 1);
439 assert!(!report.view.summary.coverage_complete);
440 }
441
442 #[test]
443 fn rejects_hidden_limitations_undeclared_runners_and_missing_exact_scope() {
444 let mut hidden = declaration();
445 hidden.structural_limitations.clear();
446 assert!(matches!(
447 validate_frontend_report_request(&hidden, &request()),
448 Err(FrontendProtocolError::StructuralLimitationMismatch { .. })
449 ));
450
451 let mut undeclared = request();
452 undeclared.raw_results[0].provenance.runner = "other".into();
453 assert!(matches!(
454 validate_frontend_report_request(&declaration(), &undeclared),
455 Err(FrontendProtocolError::UndeclaredRunner(runner)) if runner == "other"
456 ));
457
458 let mut missing_scope = request();
459 missing_scope.raw_results[0].scope = None;
460 assert!(matches!(
461 validate_frontend_report_request(&declaration(), &missing_scope),
462 Err(FrontendProtocolError::MissingExactIdentity { axis: "worker", .. })
463 ));
464
465 let mut unknown_phase = request();
466 unknown_phase.raw_results[0].runtime[0].events[0].phase_id = Some("other".into());
467 assert!(matches!(
468 validate_frontend_report_request(&declaration(), &unknown_phase),
469 Err(FrontendProtocolError::UnknownPhaseReference(id)) if id == "other"
470 ));
471
472 let mut cyclic_phase = request();
473 cyclic_phase.raw_results[0].phases[0].caused_by_phase_id = Some("assertion".into());
474 assert!(matches!(
475 validate_frontend_report_request(&declaration(), &cyclic_phase),
476 Err(FrontendProtocolError::CyclicPhaseReference(id)) if id == "assertion"
477 ));
478 }
479}