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 if let Some(phase) = &snapshot.phase_id {
280 phase_reference(phase)?;
281 }
282 }
283 for record in &raw.server {
284 if let Some(phase) = &record.phase_id {
285 phase_reference(phase)?;
286 }
287 }
288 Ok(())
289}
290
291pub fn validate_frontend_report_request(
292 declaration: &FrontendRunDeclaration,
293 request: &CoverageReportRequest,
294) -> Result<(), FrontendProtocolError> {
295 validate_frontend_run_declaration(declaration)?;
296 let manifest = manifest_limitation_ids(request)?;
297 let declared = declaration
298 .structural_limitations
299 .iter()
300 .cloned()
301 .collect::<BTreeSet<_>>();
302 if declared != manifest {
303 return Err(FrontendProtocolError::StructuralLimitationMismatch {
304 declared: declared.into_iter().collect(),
305 manifest: manifest.into_iter().collect(),
306 });
307 }
308
309 let runners = declaration
310 .runners
311 .iter()
312 .map(|runner| (runner.runner.as_str(), runner))
313 .collect::<BTreeMap<_, _>>();
314 let mut observed = BTreeSet::new();
315 let mut phase_ids = BTreeSet::new();
316 for raw in &request.raw_results {
317 let name = raw.provenance.runner.as_str();
318 let runner = runners
319 .get(name)
320 .ok_or_else(|| FrontendProtocolError::UndeclaredRunner(name.to_owned()))?;
321 observed.insert(name);
322 require_exact_identities(runner, raw, &request.run_id, &mut phase_ids)?;
323 }
324 for runner in runners.keys() {
325 if !observed.contains(runner) {
326 return Err(FrontendProtocolError::UnobservedRunner(
327 (*runner).to_owned(),
328 ));
329 }
330 }
331 Ok(())
332}
333
334pub fn analyze_frontend_results(
335 declaration: &FrontendRunDeclaration,
336 request: &CoverageReportRequest,
337) -> Result<CoverageReport, FrontendProtocolError> {
338 validate_frontend_report_request(declaration, request)?;
339 analyze_coverage_results(request).map_err(FrontendProtocolError::Analysis)
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345 use crate::{
346 coverage_analysis::PointKind,
347 coverage_report::{
348 CoverageManifest, CoveragePhase, ExecutionScope, ExitCodeInput, PointMeta,
349 RuntimeEvent, RuntimeSnapshot, TestProvenance,
350 },
351 };
352 use supercov_contracts::{
353 ExecutionModel, FrontendAttribution, FrontendLimitation, FrontendLimitationScope,
354 FrontendRunnerDeclaration, LANGUAGE_FRONTEND_PROTOCOL_VERSION, StructuralSource,
355 };
356
357 fn declaration() -> FrontendRunDeclaration {
358 FrontendRunDeclaration {
359 protocol_version: LANGUAGE_FRONTEND_PROTOCOL_VERSION,
360 frontend_id: "fixture".into(),
361 frontend_version: "fixture-v1".into(),
362 language: "fixture".into(),
363 structural_source: StructuralSource::NativeImport,
364 runners: vec![FrontendRunnerDeclaration {
365 runner: "fixture-runner".into(),
366 execution_model: ExecutionModel::SerialInProcess,
367 attribution: FrontendAttribution {
368 run: AttributionPrecision::Exact,
369 worker: AttributionPrecision::Exact,
370 test: AttributionPrecision::Exact,
371 retry: AttributionPrecision::Exact,
372 phase: AttributionPrecision::Exact,
373 action: AttributionPrecision::Unavailable,
374 assertion: AttributionPrecision::Exact,
375 },
376 limitations: vec![FrontendLimitation {
377 id: "no-action-hook".into(),
378 scopes: vec![FrontendLimitationScope::Action],
379 reason: "The fixture runner has no action lifecycle".into(),
380 }],
381 }],
382 structural_limitations: vec!["dynamic-fixture".into()],
383 }
384 }
385
386 fn request() -> CoverageReportRequest {
387 CoverageReportRequest {
388 run_id: "run".into(),
389 manifest: CoverageManifest {
390 unmeasured: Vec::new(),
391 decisions: vec![],
392 points: vec![PointMeta {
393 id: "point".into(),
394 kind: PointKind::Statement,
395 file: "src/example.py".into(),
396 line: 1,
397 column: 1,
398 source: "work()".into(),
399 label: None,
400 }],
401 branches: vec![],
402 limitations: vec![serde_json::json!({
403 "id": "dynamic-fixture",
404 "kind": "dynamic-code",
405 "file": "src/example.py",
406 "line": 2,
407 "column": 1,
408 "source": "eval(source)",
409 "reason": "Runtime source has no stable denominator"
410 })],
411 scope: None,
412 assertion_sites: Vec::new(),
413 },
414 raw_results: vec![RawTestResult {
415 test_id: Some("test".into()),
416 scope: Some(ExecutionScope {
417 version: 1,
418 run_id: "run".into(),
419 worker_id: "worker".into(),
420 test_id: "test".into(),
421 test_key: "test".into(),
422 retry: 0,
423 attempt_id: "attempt".into(),
424 }),
425 test: "test".into(),
426 test_file: Some("tests/test_example.py".into()),
427 title: Some("test".into()),
428 retry: Some(0),
429 status: Some("passed".into()),
430 expected_status: Some("passed".into()),
431 flaky: false,
432 provenance: TestProvenance {
433 runner: "fixture-runner".into(),
434 kind: "integration".into(),
435 project: None,
436 source: "explicit".into(),
437 },
438 role: "test".into(),
439 attribution: crate::coverage_report::ATTRIBUTION_EXACT.into(),
440 phases: vec![CoveragePhase {
441 id: "assertion".into(),
442 kind: "assertion".into(),
443 operation: "assert result".into(),
444 source: Some("tests/test_example.py:1".into()),
445 caused_by_phase_id: None,
446 started_at_ms: 1,
447 ended_at_ms: Some(2),
448 status: Some("passed".into()),
449 error: None,
450 }],
451 runtime: vec![RuntimeSnapshot {
452 decisions: vec![],
453 hits: vec!["point".into()],
454 events: vec![RuntimeEvent {
455 event_type: "hit".into(),
456 id: "point".into(),
457 vector: None,
458 timestamp_ms: 1,
459 phase_id: Some("assertion".into()),
460 statement_id: None,
461 environment: "fixture".into(),
462 }],
463 logicals: vec![],
464 phase_id: None,
465 }],
466 browser: vec![],
467 server: vec![],
468 }],
469 generated_at: "2026-08-25T00:00:00.000Z".into(),
470 coverage_model: None,
471 integrity: None,
472 test_exit_code: ExitCodeInput::Present(Some(0)),
473 }
474 }
475
476 #[test]
477 fn validates_a_declared_frontend_before_shared_analysis() {
478 let report = analyze_frontend_results(&declaration(), &request()).unwrap();
479 assert!(report.execution.unwrap().valid);
480 assert_eq!(report.view.summary.statements.covered, 1);
481 assert!(!report.view.summary.coverage_complete);
482 }
483
484 #[test]
485 fn rejects_hidden_limitations_undeclared_runners_and_missing_exact_scope() {
486 let mut hidden = declaration();
487 hidden.structural_limitations.clear();
488 assert!(matches!(
489 validate_frontend_report_request(&hidden, &request()),
490 Err(FrontendProtocolError::StructuralLimitationMismatch { .. })
491 ));
492
493 let mut undeclared = request();
494 undeclared.raw_results[0].provenance.runner = "other".into();
495 assert!(matches!(
496 validate_frontend_report_request(&declaration(), &undeclared),
497 Err(FrontendProtocolError::UndeclaredRunner(runner)) if runner == "other"
498 ));
499
500 let mut missing_scope = request();
501 missing_scope.raw_results[0].scope = None;
502 assert!(matches!(
503 validate_frontend_report_request(&declaration(), &missing_scope),
504 Err(FrontendProtocolError::MissingExactIdentity { axis: "worker", .. })
505 ));
506
507 let mut unknown_phase = request();
508 unknown_phase.raw_results[0].runtime[0].events[0].phase_id = Some("other".into());
509 assert!(matches!(
510 validate_frontend_report_request(&declaration(), &unknown_phase),
511 Err(FrontendProtocolError::UnknownPhaseReference(id)) if id == "other"
512 ));
513
514 let mut cyclic_phase = request();
515 cyclic_phase.raw_results[0].phases[0].caused_by_phase_id = Some("assertion".into());
516 assert!(matches!(
517 validate_frontend_report_request(&declaration(), &cyclic_phase),
518 Err(FrontendProtocolError::CyclicPhaseReference(id)) if id == "assertion"
519 ));
520 }
521
522 #[test]
523 fn selected_but_unstarted_tests_have_no_invented_attempt_identity() {
524 let mut unstarted = request();
525 let raw = &mut unstarted.raw_results[0];
526 raw.scope = None;
527 raw.retry = None;
528 raw.status = Some("unstarted".into());
529 raw.phases.clear();
530 raw.runtime.clear();
531 assert!(validate_frontend_report_request(&declaration(), &unstarted).is_ok());
532
533 let mut false_attempt = unstarted.clone();
534 false_attempt.raw_results[0].retry = Some(0);
535 assert!(matches!(
536 validate_frontend_report_request(&declaration(), &false_attempt),
537 Err(FrontendProtocolError::InvalidUnstartedResult(_))
538 ));
539
540 let mut false_observation = unstarted;
541 false_observation.raw_results[0].runtime = request().raw_results[0].runtime.clone();
542 assert!(matches!(
543 validate_frontend_report_request(&declaration(), &false_observation),
544 Err(FrontendProtocolError::InvalidUnstartedResult(_))
545 ));
546 }
547}