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