1use std::{
19 collections::{BTreeMap, BTreeSet},
20 fs::{self, File},
21 path::{Component, Path, PathBuf},
22};
23
24use memmap2::{Mmap, MmapOptions};
25use serde::Deserialize;
26use serde_json::json;
27use sha2::{Digest, Sha256};
28use supercov_contracts::{
29 AttributionPrecision, ExecutionModel, FrontendAttribution, FrontendLimitation,
30 FrontendLimitationScope, FrontendRunDeclaration, FrontendRunnerDeclaration,
31 LANGUAGE_FRONTEND_PROTOCOL_VERSION, StructuralSource,
32};
33
34use crate::{
35 coverage_analysis::McdcVector,
36 coverage_report::{
37 CoverageManifest, CoverageModelDeclaration, CoveragePhase, CoverageReportRequest,
38 DecisionMeta, DecisionSnapshot, ExecutionScope, ExitCodeInput, PersistedCoverageModel,
39 RawTestResult, RuntimeSnapshot, TestProvenance,
40 },
41 evidence_archive::EvidenceArchiveEntry,
42};
43
44pub const PYTHON_EVIDENCE_VERSION: u32 = 1;
45pub const PYTHON_FRONTEND_VERSION: &str = "python-probes-v1";
46pub const PYTEST_RUNNER: &str = "pytest";
47pub const UNITTEST_RUNNER: &str = "unittest";
48
49const TRANSPORT_MAGIC: &[u8; 8] = b"SCVPYTH1";
50const TRANSPORT_VERSION: u32 = 2;
55const TRANSPORT_VERSION_FNV: u32 = 1;
56const TRANSPORT_HEADER_SIZE: usize = 64;
57const TRANSPORT_RECORD_HEADER_SIZE: usize = 16;
58const TRANSPORT_MAX_RECORD_SIZE: usize = 4 * 1024 * 1024;
59
60fn default_runner() -> String {
61 PYTEST_RUNNER.into()
62}
63
64fn python_runner(runner: &str) -> bool {
65 matches!(runner, PYTEST_RUNNER | UNITTEST_RUNNER)
66}
67
68#[derive(Debug, Deserialize)]
71#[allow(dead_code)]
72#[serde(tag = "t", rename_all = "lowercase", deny_unknown_fields)]
73enum Record {
74 Process {
75 v: u32,
76 run: String,
77 pid: u64,
78 worker: String,
79 python: String,
80 executable: String,
81 argv: Vec<String>,
82 #[serde(default)]
85 frontend: Option<String>,
86 },
87 Worker {
88 worker: String,
89 },
90 Phase {
91 ctx: u64,
92 at: i64,
93 worker: String,
94 test: String,
95 retry: usize,
96 phase: String,
97 },
98 Outcome {
99 worker: String,
100 test: String,
101 retry: usize,
102 phase: String,
103 outcome: String,
104 xfail: bool,
105 #[serde(default = "default_runner")]
106 runner: String,
107 #[serde(default)]
110 file: Option<String>,
111 },
112 Hit {
113 ctx: u64,
114 id: String,
115 },
116 Hits {
120 ctx: u64,
121 ids: Vec<String>,
122 },
123 #[serde(skip)]
126 SlotHits {
127 ctx: u64,
128 positions: Vec<u32>,
129 },
130 Dec {
131 ctx: u64,
132 id: String,
133 v: String,
134 o: u8,
135 },
136 Decs {
138 ctx: u64,
139 v: Vec<(String, String, u8)>,
140 },
141 Runs {
147 ctx: u64,
148 r: Vec<usize>,
149 },
150 Assert {
153 ctx: u64,
154 },
155 Limitation {
160 id: String,
161 reason: String,
162 #[serde(default)]
163 file: Option<String>,
164 #[serde(default)]
165 obligation: Option<String>,
166 },
167 Exit {
168 at: i64,
169 },
170}
171
172#[derive(Debug)]
173pub enum PythonEvidenceError {
174 Io(String),
175 UnsafeEntry(String),
176 InvalidRecord {
177 file: String,
178 line: usize,
179 reason: String,
180 },
181 InvalidTransport {
182 file: String,
183 reason: String,
184 },
185 DroppedRecords {
186 file: String,
187 count: u64,
188 },
189 RunMismatch {
190 expected: String,
191 actual: String,
192 },
193 UnsupportedVersion(u32),
194 UnknownContext {
195 file: String,
196 line: usize,
197 context: u64,
198 },
199 UnknownObligation(String),
200 InvalidVector {
201 id: String,
202 expected: usize,
203 actual: usize,
204 },
205 NoInterpreter,
206 NoTests,
207 UnsupportedPython(String),
208}
209
210impl std::fmt::Display for PythonEvidenceError {
211 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
212 match self {
213 Self::Io(reason) => write!(formatter, "could not read Python evidence: {reason}"),
214 Self::UnsafeEntry(name) => write!(formatter, "unsafe Python evidence entry: {name}"),
215 Self::InvalidRecord { file, line, reason } => {
216 write!(formatter, "invalid Python evidence record {file}:{line}: {reason}")
217 }
218 Self::InvalidTransport { file, reason } => {
219 write!(formatter, "invalid Python evidence transport {file}: {reason}")
220 }
221 Self::DroppedRecords { file, count } => write!(
222 formatter,
223 "Python evidence transport {file} exhausted its bounded capacity and dropped {count} record(s)"
224 ),
225 Self::RunMismatch { expected, actual } => write!(
226 formatter,
227 "Python evidence belongs to run {actual}, expected {expected}"
228 ),
229 Self::UnsupportedVersion(version) => {
230 write!(formatter, "unsupported Python evidence version {version}")
231 }
232 Self::UnknownContext { file, line, context } => write!(
233 formatter,
234 "Python evidence {file}:{line} references undeclared context {context}"
235 ),
236 Self::UnknownObligation(id) => {
237 write!(formatter, "Python runtime reported an unknown obligation: {id}")
238 }
239 Self::InvalidVector {
240 id,
241 expected,
242 actual,
243 } => write!(
244 formatter,
245 "Python decision {id} reported {actual} condition values, expected {expected}"
246 ),
247 Self::NoInterpreter => formatter.write_str(
248 "no Supercov-hooked Python interpreter ran: the test command did not start CPython 3.9+ with Supercov's start-up hook (PYTHONPATH may be ignored by -I/-E/-S, or the runner is not Python)",
249 ),
250 Self::NoTests => formatter.write_str(
251 "the Python run produced no test outcomes; Supercov measures Python through pytest and unittest",
252 ),
253 Self::UnsupportedPython(version) => write!(
254 formatter,
255 "Supercov measures CPython 3.9 or newer; the test command ran Python {version}"
256 ),
257 }
258 }
259}
260
261impl std::error::Error for PythonEvidenceError {}
262
263fn stable_id(prefix: &str, values: &[&str]) -> String {
264 let mut hash = Sha256::new();
265 for value in values {
266 hash.update(value.as_bytes());
267 hash.update([0]);
268 }
269 let digest = hash.finalize();
270 let mut encoded = String::with_capacity(prefix.len() + 25);
271 encoded.push_str(prefix);
272 encoded.push(':');
273 for byte in &digest[..12] {
274 use std::fmt::Write as _;
275 write!(&mut encoded, "{byte:02x}").expect("string formatting");
276 }
277 encoded
278}
279
280#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
281struct Identity {
282 worker: String,
283 test: String,
284 retry: usize,
285 phase: String,
286}
287
288type ObservedVectors = BTreeSet<(Vec<Option<bool>>, bool)>;
289type OutcomesByAttempt = BTreeMap<(String, String, usize), Vec<(String, String, bool)>>;
291type RunnersByAttempt = BTreeMap<(String, String, usize), String>;
293type TestFilesByAttempt = BTreeMap<(String, String, usize), String>;
295
296fn relative(root: &Path, path: &str) -> Option<String> {
300 let candidate = Path::new(path);
301 let relative = if candidate.is_absolute() {
302 candidate.strip_prefix(root).ok()?
303 } else {
304 candidate.strip_prefix("./").unwrap_or(candidate)
305 };
306 let text = relative.to_string_lossy().replace('\\', "/");
307 (!text.is_empty() && !text.starts_with("../")).then_some(text)
308}
309
310#[derive(Debug, Default)]
311struct Observations {
312 hits: BTreeSet<String>,
313 slot_hits: Vec<u32>,
318 vectors: BTreeMap<String, ObservedVectors>,
319}
320
321impl Observations {
322 fn is_empty(&self) -> bool {
323 self.hits.is_empty() && self.slot_hits.is_empty() && self.vectors.is_empty()
324 }
325}
326
327#[derive(Debug, Clone)]
328struct RuntimeLimitation {
329 id: String,
330 reason: String,
331 file: Option<String>,
332 obligation: Option<String>,
333}
334
335#[derive(Debug, Default)]
336struct Evidence {
337 interpreters: usize,
338 python_versions: BTreeSet<String>,
339 per_identity: BTreeMap<Identity, Observations>,
340 background: BTreeMap<String, Observations>,
341 outcomes: OutcomesByAttempt,
342 runners: RunnersByAttempt,
343 test_files: TestFilesByAttempt,
344 limitations: BTreeMap<(String, Option<String>, Option<String>), RuntimeLimitation>,
345 slot_ids: Vec<String>,
347 requests: Vec<(Identity, i64, usize, String, String)>,
351 served_by: BTreeMap<String, BTreeSet<(String, String, usize)>>,
354 phase_starts: Vec<crate::served_tests::PhaseStart>,
357}
358
359use crate::served_tests::REQUEST_WORKER;
360
361fn read_evidence_directory(
362 directory: &Path,
363 run_id: &str,
364) -> Result<Evidence, PythonEvidenceError> {
365 let mut evidence = Evidence::default();
366 let mut files = match fs::read_dir(directory) {
367 Ok(entries) => entries
368 .collect::<Result<Vec<_>, _>>()
369 .map_err(|error| PythonEvidenceError::Io(error.to_string()))?,
370 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
371 Err(error) => return Err(PythonEvidenceError::Io(error.to_string())),
372 };
373 files.sort_by_key(|entry| entry.file_name());
374 let mut transports = Vec::new();
375 let mut slots = BTreeMap::<String, Vec<(String, PathBuf)>>::new();
376 let mut layout_path = None;
377 for entry in files {
378 let name = entry
379 .file_name()
380 .into_string()
381 .map_err(|_| PythonEvidenceError::UnsafeEntry("<non-utf8>".into()))?;
382 if Path::new(&name)
383 .components()
384 .any(|component| !matches!(component, Component::Normal(_)))
385 {
386 return Err(PythonEvidenceError::UnsafeEntry(name));
387 }
388 if name.starts_with(SLOT_LAYOUT_NAME) && name.ends_with(".partial") {
391 continue;
392 }
393 let Some(metadata) = entry_metadata(&name, &entry.path())? else {
394 continue;
395 };
396 if !metadata.file_type().is_file() {
397 return Err(PythonEvidenceError::UnsafeEntry(name));
398 }
399 if name == SLOT_LAYOUT_NAME {
400 layout_path = Some(entry.path());
401 } else if name.ends_with(".mmap") {
402 transports.push((name, entry.path()));
403 } else if let Some((slot_name, transport)) = name.strip_suffix(".shm").and_then(|stem| {
404 let (transport, number) = stem.rsplit_once('.')?;
405 number
406 .parse::<u64>()
407 .ok()
408 .map(|_| (format!("{stem}.slot"), format!("{transport}.mmap")))
409 }) {
410 if let Some(path) = materialize_shared_slot(&entry.path(), &slot_name)? {
411 slots.entry(transport).or_default().push((slot_name, path));
412 }
413 } else if let Some(transport) = name
414 .strip_suffix(".slot")
415 .and_then(|stem| stem.rsplit_once('.'))
416 .filter(|(_, number)| number.parse::<u64>().is_ok())
417 .map(|(transport, _)| format!("{transport}.mmap"))
418 {
419 if metadata.len() > 0 {
421 slots
422 .entry(transport)
423 .or_default()
424 .push((name, entry.path()));
425 }
426 } else {
427 return Err(PythonEvidenceError::UnsafeEntry(name));
428 }
429 }
430 let layout = match layout_path {
431 Some(path) => Some(SlotLayout::read(&path)?),
432 None => None,
433 };
434 let work = transports
438 .into_iter()
439 .map(|(name, path)| {
440 let owned = slots.remove(&name).unwrap_or_default();
441 (name, path, owned)
442 })
443 .collect::<Vec<_>>();
444 let parse = |(name, path, owned): &(String, PathBuf, Vec<(String, PathBuf)>)| {
445 let contents = map_evidence(path)?;
446 let slot_contents = owned
447 .iter()
448 .filter_map(|(slot, path)| match map_slot(path) {
449 Ok(Some(contents)) => Some(Ok((slot.clone(), contents))),
450 Ok(None) => None,
451 Err(error) => Some(Err(error)),
452 })
453 .collect::<Result<Vec<_>, _>>()?;
454 parse_transport(name, &contents, &slot_contents, layout.as_ref())
455 };
456 let parsed = parallel_map(&work, parse);
457 for ((name, _, _), parsed) in work.iter().zip(parsed) {
458 apply_transport(name, parsed?, run_id, &mut evidence)?;
459 }
460 evidence.slot_ids = layout.map(|layout| layout.ids).unwrap_or_default();
461 if let Some((_, orphans)) = slots.into_iter().next() {
462 return Err(PythonEvidenceError::InvalidTransport {
463 file: orphans[0].0.clone(),
464 reason: "a slot was left without its process's transport".into(),
465 });
466 }
467 Ok(evidence)
468}
469
470fn entry_metadata(name: &str, path: &Path) -> Result<Option<fs::Metadata>, PythonEvidenceError> {
477 match fs::symlink_metadata(path) {
478 Ok(metadata) => Ok(Some(metadata)),
479 Err(error) if error.kind() == std::io::ErrorKind::NotFound && name.ends_with(".slot") => {
480 Ok(None)
481 }
482 Err(error) => Err(PythonEvidenceError::Io(error.to_string())),
483 }
484}
485
486fn materialize_shared_slot(
493 marker: &Path,
494 slot_name: &str,
495) -> Result<Option<PathBuf>, PythonEvidenceError> {
496 let slot = marker.with_file_name(slot_name);
497 if fs::symlink_metadata(&slot).is_ok() {
498 return Ok(None);
499 }
500 let contents = match fs::read(marker) {
501 Ok(contents) => contents,
502 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
503 Err(error) => return Err(PythonEvidenceError::Io(error.to_string())),
504 };
505 if contents.is_empty() {
507 return Ok(None);
508 }
509 let unsafe_marker = || PythonEvidenceError::UnsafeEntry(marker.to_string_lossy().into_owned());
511 let (name, size) = std::str::from_utf8(&contents)
512 .ok()
513 .and_then(|text| text.split_once(' '))
514 .and_then(|(name, size)| Some((name.as_bytes(), size.parse::<usize>().ok()?)))
515 .ok_or_else(unsafe_marker)?;
516 if name.len() > 31
517 || name.first() != Some(&b'/')
518 || !name[1..]
519 .iter()
520 .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'.')
521 {
522 return Err(unsafe_marker());
523 }
524 shared_slot_bytes(name)?
525 .filter(|bytes| !bytes.is_empty())
527 .map(|mut bytes| {
528 if bytes.len() < size {
529 return Err(PythonEvidenceError::InvalidTransport {
530 file: slot_name.into(),
531 reason: "a shared slot is smaller than its process sized it".into(),
532 });
533 }
534 bytes.truncate(size);
535 let partial = marker.with_file_name(format!("{slot_name}.partial"));
536 fs::write(&partial, bytes)
537 .and_then(|()| fs::rename(&partial, &slot))
538 .map_err(|error| PythonEvidenceError::Io(error.to_string()))?;
539 Ok(slot)
540 })
541 .transpose()
542}
543
544#[cfg(target_os = "macos")]
546fn shared_slot_bytes(name: &[u8]) -> Result<Option<Vec<u8>>, PythonEvidenceError> {
547 use std::os::fd::FromRawFd;
548 let name = std::ffi::CString::new(name)
549 .map_err(|_| PythonEvidenceError::UnsafeEntry("<shared slot name>".into()))?;
550 let descriptor = unsafe { libc::shm_open(name.as_ptr(), libc::O_RDONLY) };
552 if descriptor < 0 {
553 let error = std::io::Error::last_os_error();
554 return if error.kind() == std::io::ErrorKind::NotFound {
555 Ok(None)
556 } else {
557 Err(PythonEvidenceError::Io(error.to_string()))
558 };
559 }
560 let file = unsafe { File::from_raw_fd(descriptor) };
562 let length = file
563 .metadata()
564 .map_err(|error| PythonEvidenceError::Io(error.to_string()))?
565 .len();
566 let bytes = if length == 0 {
567 Vec::new()
568 } else {
569 unsafe { MmapOptions::new().map(&file) }
571 .map_err(|error| PythonEvidenceError::Io(error.to_string()))?
572 .to_vec()
573 };
574 unsafe { libc::shm_unlink(name.as_ptr()) };
576 Ok(Some(bytes))
577}
578
579#[cfg(not(target_os = "macos"))]
580fn shared_slot_bytes(_name: &[u8]) -> Result<Option<Vec<u8>>, PythonEvidenceError> {
581 Ok(None)
582}
583
584fn map_slot(path: &Path) -> Result<Option<Mmap>, PythonEvidenceError> {
588 match File::open(path) {
589 Ok(file) => unsafe { MmapOptions::new().map(&file) }
590 .map(Some)
591 .map_err(|error| PythonEvidenceError::Io(error.to_string())),
592 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
593 Err(error) => Err(PythonEvidenceError::Io(error.to_string())),
594 }
595}
596
597fn map_evidence(path: &Path) -> Result<Mmap, PythonEvidenceError> {
598 let file = File::open(path).map_err(|error| PythonEvidenceError::Io(error.to_string()))?;
599 unsafe { MmapOptions::new().map(&file) }
603 .map_err(|error| PythonEvidenceError::Io(error.to_string()))
604}
605
606const SLOT_LAYOUT_NAME: &str = "layout.json";
607const SLOT_MAX_REGION_WIDTH: usize = 6;
610
611#[derive(Debug, Deserialize)]
614#[serde(rename_all = "camelCase", deny_unknown_fields)]
615struct SlotLayout {
616 #[allow(dead_code)]
617 version: u32,
618 digest: String,
619 header: usize,
620 bytes: usize,
621 ids: Vec<String>,
622 decisions: Vec<SlotDecision>,
623 #[serde(default)]
627 implied: Vec<(usize, Vec<usize>)>,
628 #[serde(skip)]
629 implied_by: std::collections::HashMap<usize, Vec<usize>>,
630}
631
632#[derive(Debug, Deserialize)]
633#[serde(rename_all = "camelCase", deny_unknown_fields)]
634struct SlotDecision {
635 start: usize,
636 width: usize,
637 id: String,
638 outcome_true: String,
639 outcome_false: String,
640 logical: Vec<SlotLogical>,
641}
642
643#[derive(Debug, Deserialize)]
644#[serde(rename_all = "camelCase", deny_unknown_fields)]
645struct SlotLogical {
646 evaluated: String,
647 short_circuit: String,
648 previous_leaves: Vec<usize>,
649 operand_leaves: Vec<usize>,
650}
651
652impl SlotDecision {
653 fn region_bytes(&self) -> usize {
656 if self.width == 1 {
657 2
658 } else {
659 2 * 3_usize.pow(self.width as u32)
660 }
661 }
662
663 fn vector(&self, offset: usize) -> (String, bool) {
665 if self.width == 1 {
666 let outcome = offset == 1;
667 return ((if outcome { "2" } else { "1" }).into(), outcome);
668 }
669 let mut mask = offset >> 1;
670 let digits = (0..self.width)
671 .map(|_| {
672 let digit = b"012"[mask % 3] as char;
673 mask /= 3;
674 digit
675 })
676 .collect();
677 (digits, offset & 1 == 1)
678 }
679
680 fn implied(&self, digits: &str, outcome: bool, into: &mut Vec<String>) {
683 into.push(
684 if outcome {
685 &self.outcome_true
686 } else {
687 &self.outcome_false
688 }
689 .clone(),
690 );
691 let evaluated = |leaves: &[usize]| {
692 leaves.iter().any(|index| {
693 digits
694 .as_bytes()
695 .get(*index)
696 .is_some_and(|digit| *digit != b'0')
697 })
698 };
699 for logical in &self.logical {
700 if evaluated(&logical.operand_leaves) {
701 into.push(logical.evaluated.clone());
702 } else if evaluated(&logical.previous_leaves) {
703 into.push(logical.short_circuit.clone());
704 }
705 }
706 }
707}
708
709impl SlotLayout {
710 fn read(path: &Path) -> Result<Self, PythonEvidenceError> {
711 let invalid = |reason: String| PythonEvidenceError::InvalidTransport {
712 file: SLOT_LAYOUT_NAME.into(),
713 reason,
714 };
715 let bytes = fs::read(path).map_err(|error| PythonEvidenceError::Io(error.to_string()))?;
716 let mut layout: Self =
717 serde_json::from_slice(&bytes).map_err(|error| invalid(error.to_string()))?;
718 if layout.digest.len() < 16 || !layout.digest.bytes().all(|byte| byte.is_ascii_hexdigit()) {
719 return Err(invalid("the layout digest is not hex".into()));
720 }
721 let mut cursor = layout.header + layout.ids.len();
724 for decision in &layout.decisions {
725 if decision.start != cursor
726 || decision.width == 0
727 || decision.width > SLOT_MAX_REGION_WIDTH
728 {
729 return Err(invalid(format!(
730 "decision {} has no valid region",
731 decision.id
732 )));
733 }
734 if decision
735 .logical
736 .iter()
737 .flat_map(|logical| {
738 logical
739 .previous_leaves
740 .iter()
741 .chain(&logical.operand_leaves)
742 })
743 .any(|leaf| *leaf >= decision.width)
744 {
745 return Err(invalid(format!(
746 "decision {} names a condition it lacks",
747 decision.id
748 )));
749 }
750 cursor += decision.region_bytes();
751 }
752 if cursor != layout.bytes || layout.header < 16 {
753 return Err(invalid("the regions do not fill the slot".into()));
754 }
755 let id_end = layout.header + layout.ids.len();
758 for (head, implied) in &layout.implied {
759 if *head < layout.header
760 || *head >= id_end
761 || implied
762 .iter()
763 .any(|byte| *byte < layout.header || *byte >= layout.bytes || *byte == *head)
764 {
765 return Err(invalid(format!(
766 "byte {head} implies bytes outside the slot"
767 )));
768 }
769 }
770 layout.implied_by = layout.implied.iter().cloned().collect();
771 if layout
772 .implied_by
773 .values()
774 .flatten()
775 .any(|byte| layout.implied_by.contains_key(byte))
776 {
777 return Err(invalid("an implied byte implies others".into()));
778 }
779 Ok(layout)
780 }
781
782 fn tag(&self) -> Vec<u8> {
783 (0..8)
784 .map(|index| {
785 u8::from_str_radix(&self.digest[index * 2..index * 2 + 2], 16).unwrap_or(0)
786 })
787 .collect()
788 }
789
790 fn records(&self, name: &str, contents: &[u8]) -> Result<Vec<Record>, PythonEvidenceError> {
792 let invalid = |reason: &str| PythonEvidenceError::InvalidTransport {
793 file: name.into(),
794 reason: reason.into(),
795 };
796 let tag = contents
797 .get(8..16)
798 .ok_or_else(|| invalid("slot header is missing"))?;
799 if tag.iter().all(|byte| *byte == 0) {
802 return Ok(Vec::new());
803 }
804 if tag != self.tag().as_slice() {
805 return Err(invalid("slot was written for another layout"));
806 }
807 if contents.len() != self.bytes {
808 return Err(invalid("slot size does not match the layout"));
809 }
810 let context =
811 transport_u64(contents, 0).ok_or_else(|| invalid("slot context is missing"))?;
812 let mut decoded = Decoded::default();
813 for (index, byte) in contents.iter().enumerate().skip(self.header) {
814 match byte {
815 0 => continue,
816 1 => self.decode(index, &mut decoded),
817 _ => return Err(invalid("slot byte is neither 0 nor 1")),
818 }
819 }
820 Ok(decoded.into_records(context))
821 }
822
823 fn runs(&self, context: u64, runs: &[usize]) -> Result<Vec<Record>, &'static str> {
825 if !runs.len().is_multiple_of(2) {
826 return Err("slot runs must pair a first byte with a length");
827 }
828 let mut decoded = Decoded::default();
829 for run in runs.chunks_exact(2) {
830 let (start, length) = (run[0], run[1]);
831 let end = start
832 .checked_add(length)
833 .filter(|end| length > 0 && start >= self.header && *end <= self.bytes)
834 .ok_or("a slot run lies outside the layout")?;
835 for index in start..end {
836 self.decode(index, &mut decoded);
837 }
838 }
839 Ok(decoded.into_records(context))
840 }
841
842 fn decode(&self, index: usize, into: &mut Decoded) {
844 self.decode_one(index, into);
845 if let Some(implied) = self.implied_by.get(&index) {
846 for byte in implied {
847 self.decode_one(*byte, into);
848 }
849 }
850 }
851
852 fn decode_one(&self, index: usize, into: &mut Decoded) {
855 let id_end = self.header + self.ids.len();
856 if index < id_end {
857 into.positions.push((index - self.header) as u32);
858 return;
859 }
860 let decision = &self.decisions[self
861 .decisions
862 .partition_point(|decision| decision.start <= index)
863 - 1];
864 let (digits, outcome) = decision.vector(index - decision.start);
865 decision.implied(&digits, outcome, &mut into.ids);
866 into.vectors
867 .push((decision.id.clone(), digits, u8::from(outcome)));
868 }
869}
870
871#[derive(Default)]
872struct Decoded {
873 ids: Vec<String>,
874 positions: Vec<u32>,
875 vectors: Vec<(String, String, u8)>,
876}
877
878impl Decoded {
879 fn into_records(self, context: u64) -> Vec<Record> {
880 let mut records = Vec::new();
881 if !self.positions.is_empty() {
882 records.push(Record::SlotHits {
883 ctx: context,
884 positions: self.positions,
885 });
886 }
887 if !self.ids.is_empty() {
888 records.push(Record::Hits {
889 ctx: context,
890 ids: self.ids,
891 });
892 }
893 if !self.vectors.is_empty() {
894 records.push(Record::Decs {
895 ctx: context,
896 v: self.vectors,
897 });
898 }
899 records
900 }
901}
902
903fn parallel_map<T: Sync, R: Send>(items: &[T], work: impl Fn(&T) -> R + Sync) -> Vec<R> {
905 let threads = std::thread::available_parallelism()
906 .map_or(1, std::num::NonZeroUsize::get)
907 .min(items.len().div_ceil(64).max(1));
908 if threads <= 1 {
909 return items.iter().map(work).collect();
910 }
911 let next = std::sync::atomic::AtomicUsize::new(0);
912 let mut done = std::thread::scope(|scope| {
913 let workers = (0..threads)
914 .map(|_| {
915 scope.spawn(|| {
916 let mut done = Vec::new();
917 loop {
918 let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
919 let Some(item) = items.get(index) else {
920 break done;
921 };
922 done.push((index, work(item)));
923 }
924 })
925 })
926 .collect::<Vec<_>>();
927 workers
928 .into_iter()
929 .flat_map(|worker| {
930 worker
931 .join()
932 .unwrap_or_else(|panic| std::panic::resume_unwind(panic))
933 })
934 .collect::<Vec<_>>()
935 });
936 done.sort_unstable_by_key(|(index, _)| *index);
937 done.into_iter().map(|(_, result)| result).collect()
938}
939
940fn transport_u32(bytes: &[u8], offset: usize) -> Option<u32> {
941 bytes
942 .get(offset..offset + 4)
943 .and_then(|value| value.try_into().ok())
944 .map(u32::from_le_bytes)
945}
946
947fn transport_u64(bytes: &[u8], offset: usize) -> Option<u64> {
948 bytes
949 .get(offset..offset + 8)
950 .and_then(|value| value.try_into().ok())
951 .map(u64::from_le_bytes)
952}
953
954fn checked_u32(bytes: &[u8], offset: usize) -> u32 {
956 transport_u32(bytes, offset).expect("the header was checked to hold this field")
957}
958
959fn checked_u64(bytes: &[u8], offset: usize) -> u64 {
960 transport_u64(bytes, offset).expect("the header was checked to hold this field")
961}
962
963fn transport_checksum(payload: &[u8], version: u32) -> u32 {
964 if version == TRANSPORT_VERSION_FNV {
965 return payload.iter().fold(0x811c_9dc5_u32, |value, byte| {
966 (value ^ u32::from(*byte)).wrapping_mul(0x0100_0193)
967 });
968 }
969 crc32fast::hash(payload)
970}
971
972fn align_transport(value: usize) -> Option<usize> {
973 value.checked_add(7).map(|value| value & !7)
974}
975
976struct ParsedTransport {
980 pid: u64,
981 records: Vec<(usize, Record)>,
982}
983
984fn parse_transport(
985 name: &str,
986 contents: &[u8],
987 slots: &[(String, Mmap)],
988 layout: Option<&SlotLayout>,
989) -> Result<ParsedTransport, PythonEvidenceError> {
990 let invalid_transport = |reason: &str| PythonEvidenceError::InvalidTransport {
991 file: name.into(),
992 reason: reason.into(),
993 };
994 let version = transport_u32(contents, 8);
995 if contents.len() < TRANSPORT_HEADER_SIZE
996 || contents.get(..8) != Some(TRANSPORT_MAGIC.as_slice())
997 || !matches!(version, Some(TRANSPORT_VERSION | TRANSPORT_VERSION_FNV))
998 || transport_u32(contents, 12) != Some(TRANSPORT_HEADER_SIZE as u32)
999 {
1000 return Err(invalid_transport("header or version does not match"));
1001 }
1002 let version = version.unwrap_or(TRANSPORT_VERSION);
1003 let declared_capacity = checked_u64(contents, 16);
1004 if declared_capacity < TRANSPORT_HEADER_SIZE as u64 || declared_capacity > contents.len() as u64
1005 {
1006 return Err(invalid_transport(
1007 "declared capacity is outside the mapped file",
1008 ));
1009 }
1010 let dropped = checked_u64(contents, 24);
1011 if dropped != 0 {
1012 return Err(PythonEvidenceError::DroppedRecords {
1013 file: name.into(),
1014 count: dropped,
1015 });
1016 }
1017 let transport_pid = checked_u64(contents, 32);
1018 if transport_pid == 0 {
1019 return Err(invalid_transport("process id is missing"));
1020 }
1021 let mut records = Vec::new();
1022 let mut cursor = TRANSPORT_HEADER_SIZE;
1023 let mut record_index = 0;
1024 while cursor + TRANSPORT_RECORD_HEADER_SIZE <= contents.len() {
1025 let commit = contents[cursor];
1026 if commit == 0 {
1027 break;
1030 }
1031 record_index += 1;
1032 let line_number = record_index;
1033 let invalid = |reason: &str| PythonEvidenceError::InvalidRecord {
1034 file: name.into(),
1035 line: line_number,
1036 reason: reason.into(),
1037 };
1038 if commit != 1
1039 || contents[cursor + 1..cursor + 4] != [0, 0, 0]
1040 || contents[cursor + 12..cursor + 16] != [0, 0, 0, 0]
1041 {
1042 return Err(invalid("commit marker or reserved bytes are invalid"));
1043 }
1044 let length = checked_u32(contents, cursor + 4) as usize;
1045 if length == 0 || length > TRANSPORT_MAX_RECORD_SIZE {
1046 return Err(invalid("payload length is outside the transport bound"));
1047 }
1048 let payload_start = cursor + TRANSPORT_RECORD_HEADER_SIZE;
1049 let payload_end = payload_start
1050 .checked_add(length)
1051 .filter(|end| *end <= contents.len())
1052 .ok_or_else(|| invalid("payload extends past the mapped file"))?;
1053 let next_cursor = align_transport(payload_end)
1054 .filter(|end| *end <= contents.len())
1055 .ok_or_else(|| invalid("aligned frame extends past the mapped file"))?;
1056 if contents[payload_end..next_cursor]
1057 .iter()
1058 .any(|byte| *byte != 0)
1059 {
1060 return Err(invalid("frame padding is not zero"));
1061 }
1062 let payload = &contents[payload_start..payload_end];
1063 let expected_checksum = checked_u32(contents, cursor + 8);
1064 if transport_checksum(payload, version) != expected_checksum {
1065 return Err(invalid("payload checksum does not match"));
1066 }
1067 let record: Record = serde_json::from_slice(payload).map_err(|error| {
1068 PythonEvidenceError::InvalidRecord {
1069 file: name.into(),
1070 line: line_number,
1071 reason: error.to_string(),
1072 }
1073 })?;
1074 if let Record::Runs { ctx, r } = record {
1075 let layout =
1076 layout.ok_or_else(|| invalid("slot runs arrived without the run's slot layout"))?;
1077 for record in layout.runs(ctx, &r).map_err(invalid)? {
1078 records.push((line_number, record));
1079 }
1080 } else {
1081 records.push((line_number, record));
1082 }
1083 cursor = next_cursor;
1084 }
1085 for (slot, slot_contents) in slots {
1088 let layout = layout.ok_or_else(|| PythonEvidenceError::InvalidTransport {
1089 file: slot.clone(),
1090 reason: "a slot was left without the run's slot layout".into(),
1091 })?;
1092 for record in layout.records(slot, slot_contents)? {
1093 record_index += 1;
1094 records.push((record_index, record));
1095 }
1096 }
1097 Ok(ParsedTransport {
1098 pid: transport_pid,
1099 records,
1100 })
1101}
1102
1103fn apply_transport(
1105 name: &str,
1106 parsed: ParsedTransport,
1107 run_id: &str,
1108 evidence: &mut Evidence,
1109) -> Result<(), PythonEvidenceError> {
1110 let transport_pid = parsed.pid;
1111 let mut contexts = BTreeMap::<u64, Identity>::new();
1112 let mut before_assertion = BTreeMap::<u64, Observations>::new();
1115 let mut process_worker: Option<String> = None;
1116 let mut apply = |line_number: usize, record: Record| -> Result<(), PythonEvidenceError> {
1117 let invalid = |reason: &str| PythonEvidenceError::InvalidRecord {
1118 file: name.into(),
1119 line: line_number,
1120 reason: reason.into(),
1121 };
1122 match record {
1123 Record::Process {
1124 v,
1125 run,
1126 pid,
1127 worker,
1128 python,
1129 ..
1130 } => {
1131 if v != PYTHON_EVIDENCE_VERSION {
1132 return Err(PythonEvidenceError::UnsupportedVersion(v));
1133 }
1134 if run != run_id {
1135 return Err(PythonEvidenceError::RunMismatch {
1136 expected: run_id.into(),
1137 actual: run,
1138 });
1139 }
1140 if pid != transport_pid {
1141 return Err(invalid("process record does not match the transport owner"));
1142 }
1143 let supported = python
1144 .split('.')
1145 .take(2)
1146 .map(|part| part.parse::<u32>().ok())
1147 .collect::<Option<Vec<_>>>()
1148 .is_some_and(|parts| parts.len() == 2 && (parts[0], parts[1]) >= (3, 9));
1151 if !supported {
1152 return Err(PythonEvidenceError::UnsupportedPython(python));
1153 }
1154 evidence.interpreters += 1;
1155 evidence.python_versions.insert(python);
1156 process_worker = Some(worker);
1157 }
1158 Record::Worker { worker } => process_worker = Some(worker),
1159 Record::Phase {
1160 ctx,
1161 at,
1162 worker,
1163 test,
1164 retry,
1165 phase,
1166 } => {
1167 if ctx == 0 {
1168 return Err(invalid("phase context 0 is reserved for background"));
1169 }
1170 if !matches!(phase.as_str(), "setup" | "call" | "teardown") {
1171 return Err(invalid("unknown pytest phase"));
1172 }
1173 if test.trim().is_empty() || worker.trim().is_empty() {
1174 return Err(invalid("phase identity must name a worker and test"));
1175 }
1176 let worker = if worker == REQUEST_WORKER {
1179 format!("{REQUEST_WORKER}:{name}")
1180 } else {
1181 worker
1182 };
1183 let identity = Identity {
1184 worker,
1185 test,
1186 retry,
1187 phase,
1188 };
1189 if identity.worker.starts_with(REQUEST_WORKER) {
1190 evidence.requests.push((
1191 identity.clone(),
1192 at,
1193 line_number,
1194 name.to_owned(),
1195 process_worker.clone().unwrap_or_else(|| "main".into()),
1196 ));
1197 } else {
1198 if identity.phase == "call" {
1199 before_assertion.insert(ctx, Observations::default());
1200 }
1201 let attempt = (
1202 identity.worker.clone(),
1203 identity.test.clone(),
1204 identity.retry,
1205 );
1206 evidence
1207 .phase_starts
1208 .push((name.to_owned(), line_number, attempt.clone()));
1209 evidence
1210 .served_by
1211 .entry(name.to_owned())
1212 .or_default()
1213 .insert(attempt);
1214 }
1215 contexts.insert(ctx, identity);
1216 }
1217 Record::Outcome {
1218 worker,
1219 test,
1220 retry,
1221 phase,
1222 outcome,
1223 xfail,
1224 runner,
1225 file,
1226 } => {
1227 if !matches!(phase.as_str(), "setup" | "call" | "teardown") {
1228 return Err(invalid("unknown test outcome phase"));
1229 }
1230 if !matches!(
1231 outcome.as_str(),
1232 "passed" | "failed" | "skipped" | "rerun" | "error"
1233 ) {
1234 return Err(invalid("unknown test outcome"));
1235 }
1236 if !matches!(runner.as_str(), PYTEST_RUNNER | UNITTEST_RUNNER) {
1237 return Err(invalid("unknown Python test runner"));
1238 }
1239 let key = (worker, test, retry);
1240 if let Some(previous) = evidence.runners.get(&key)
1241 && previous != &runner
1242 {
1243 return Err(invalid("one attempt was reported by two runners"));
1244 }
1245 evidence.runners.insert(key.clone(), runner);
1246 if let Some(file) = file.filter(|path| !path.is_empty()) {
1247 evidence.test_files.entry(key.clone()).or_insert(file);
1248 }
1249 fold_outcome(
1250 evidence.outcomes.entry(key).or_default(),
1251 phase,
1252 outcome,
1253 xfail,
1254 );
1255 }
1256 Record::Hit { ctx, id } => {
1257 if let Some(before) = before_assertion.get_mut(&ctx) {
1258 before.hits.insert(id.clone());
1259 }
1260 observations(
1261 evidence,
1262 &contexts,
1263 process_worker.as_deref(),
1264 ctx,
1265 name,
1266 line_number,
1267 )?
1268 .hits
1269 .insert(id);
1270 }
1271 Record::Hits { ids, .. } if ids.is_empty() => {}
1273 Record::SlotHits { positions, .. } if positions.is_empty() => {}
1274 Record::SlotHits { ctx, positions } => {
1275 if let Some(before) = before_assertion.get_mut(&ctx) {
1276 before.slot_hits.extend(&positions);
1277 }
1278 observations(
1279 evidence,
1280 &contexts,
1281 process_worker.as_deref(),
1282 ctx,
1283 name,
1284 line_number,
1285 )?
1286 .slot_hits
1287 .extend(positions);
1288 }
1289 Record::Decs { v, .. } if v.is_empty() => {}
1290 Record::Hits { ctx, ids } => {
1291 if let Some(before) = before_assertion.get_mut(&ctx) {
1292 before.hits.extend(ids.iter().cloned());
1293 }
1294 observations(
1296 evidence,
1297 &contexts,
1298 process_worker.as_deref(),
1299 ctx,
1300 name,
1301 line_number,
1302 )?
1303 .hits
1304 .extend(ids);
1305 }
1306 Record::Dec { ctx, id, v, o } => {
1307 if v.is_empty() || !v.bytes().all(|digit| matches!(digit, b'0' | b'1' | b'2')) {
1308 return Err(invalid("decision vector digits must be 0, 1 or 2"));
1309 }
1310 if o > 1 {
1311 return Err(invalid("decision outcome must be 0 or 1"));
1312 }
1313 let values = v
1314 .bytes()
1315 .map(|digit| match digit {
1316 b'0' => None,
1317 b'1' => Some(false),
1318 _ => Some(true),
1319 })
1320 .collect::<Vec<_>>();
1321 if let Some(before) = before_assertion.get_mut(&ctx) {
1322 before
1323 .vectors
1324 .entry(id.clone())
1325 .or_default()
1326 .insert((values.clone(), o == 1));
1327 }
1328 observations(
1329 evidence,
1330 &contexts,
1331 process_worker.as_deref(),
1332 ctx,
1333 name,
1334 line_number,
1335 )?
1336 .vectors
1337 .entry(id)
1338 .or_default()
1339 .insert((values, o == 1));
1340 }
1341 Record::Decs { ctx, v: vectors } => {
1342 let mut before = before_assertion.get_mut(&ctx);
1343 let observed = observations(
1344 evidence,
1345 &contexts,
1346 process_worker.as_deref(),
1347 ctx,
1348 name,
1349 line_number,
1350 )?;
1351 for (id, v, o) in vectors {
1352 if v.is_empty() || !v.bytes().all(|digit| matches!(digit, b'0' | b'1' | b'2')) {
1353 return Err(invalid("decision vector digits must be 0, 1 or 2"));
1354 }
1355 if o > 1 {
1356 return Err(invalid("decision outcome must be 0 or 1"));
1357 }
1358 let values = v
1359 .bytes()
1360 .map(|digit| match digit {
1361 b'0' => None,
1362 b'1' => Some(false),
1363 _ => Some(true),
1364 })
1365 .collect::<Vec<_>>();
1366 if let Some(before) = before.as_mut() {
1367 before
1368 .vectors
1369 .entry(id.clone())
1370 .or_default()
1371 .insert((values.clone(), o == 1));
1372 }
1373 observed
1374 .vectors
1375 .entry(id)
1376 .or_default()
1377 .insert((values, o == 1));
1378 }
1379 }
1380 Record::Assert { ctx } => {
1381 if let Some(before) = before_assertion.remove(&ctx) {
1384 let identity =
1385 contexts
1386 .get(&ctx)
1387 .ok_or(PythonEvidenceError::UnknownContext {
1388 file: name.into(),
1389 line: line_number,
1390 context: ctx,
1391 })?;
1392 let asserted = evidence
1393 .per_identity
1394 .entry(Identity {
1395 phase: "assertion".into(),
1396 ..identity.clone()
1397 })
1398 .or_default();
1399 asserted.hits.extend(before.hits);
1400 asserted.slot_hits.extend(before.slot_hits);
1401 for (id, vectors) in before.vectors {
1402 asserted.vectors.entry(id).or_default().extend(vectors);
1403 }
1404 }
1405 }
1406 Record::Limitation {
1407 id,
1408 reason,
1409 file,
1410 obligation,
1411 } => {
1412 evidence
1416 .limitations
1417 .entry((id.clone(), file.clone(), obligation.clone()))
1418 .or_insert(RuntimeLimitation {
1419 id,
1420 reason,
1421 file,
1422 obligation,
1423 });
1424 }
1425 Record::Runs { .. } => return Err(invalid("slot runs were not expanded")),
1427 Record::Exit { .. } => {}
1428 }
1429 Ok(())
1430 };
1431 for (line_number, record) in parsed.records {
1432 apply(line_number, record)?;
1433 }
1434 Ok(())
1435}
1436
1437fn observations<'a>(
1438 evidence: &'a mut Evidence,
1439 contexts: &BTreeMap<u64, Identity>,
1440 process_worker: Option<&str>,
1441 context: u64,
1442 file: &str,
1443 line: usize,
1444) -> Result<&'a mut Observations, PythonEvidenceError> {
1445 if context == 0 {
1446 return Ok(evidence
1447 .background
1448 .entry(process_worker.unwrap_or("main").to_owned())
1449 .or_default());
1450 }
1451 let identity = contexts
1452 .get(&context)
1453 .ok_or(PythonEvidenceError::UnknownContext {
1454 file: file.into(),
1455 line,
1456 context,
1457 })?;
1458 Ok(evidence.per_identity.entry(identity.clone()).or_default())
1459}
1460
1461pub fn python_coverage_model() -> CoverageModelDeclaration {
1464 CoverageModelDeclaration {
1465 language: "python".into(),
1466 variant: "python-owned-probes".into(),
1467 name: PYTHON_FRONTEND_VERSION.into(),
1468 completeness_meaning: "Every statement, function, decision vector, loop, short-circuit, match and exception-flow obligation Supercov derived from the source was observed through probes compiled into the code at import, with exact test identity; the declared runtime limitations remain separate.".into(),
1469 measured: vec![
1470 "executable statements, each by a probe placed before it".into(),
1471 "function and lambda entry".into(),
1472 "boolean decision vectors with masking MC/DC, each condition's value taken as the program evaluated it".into(),
1473 "for-loop and comprehension zero-versus-entered iteration".into(),
1474 "logical and/or short-circuit alternatives".into(),
1475 "match case selection and guards".into(),
1476 "try completion, handler selection and exception propagation".into(),
1477 "pytest and unittest worker, test, retry and setup/call/teardown phase identity".into(),
1478 "evidence a test recorded before its first assertion, linked to that assertion when the test passes".into(),
1479 ],
1480 not_measured: vec![
1481 "causal linkage to individual actions, or to any assertion after a test's first".into(),
1482 "code compiled from strings at runtime".into(),
1483 "measured modules imported before Supercov installed, or compiled past the import system; the run names each as a limitation".into(),
1484 "causal test context for raw _thread or native-extension-created threads".into(),
1485 "child coverage outside subprocess.Popen and multiprocessing adapters".into(),
1486 "all input values, semantic partitions, paths, or concurrency interleavings".into(),
1487 "mutation score or assertion fault-detection strength".into(),
1488 ],
1489 }
1490}
1491
1492fn phase_id(run: &str, identity: &Identity) -> String {
1493 stable_id(
1494 "python-phase",
1495 &[
1496 run,
1497 &identity.worker,
1498 &identity.test,
1499 &identity.retry.to_string(),
1500 &identity.phase,
1501 ],
1502 )
1503}
1504
1505fn scope(run: &str, worker: &str, test: &str, retry: usize) -> ExecutionScope {
1506 ExecutionScope {
1507 version: 1,
1508 run_id: run.into(),
1509 worker_id: worker.into(),
1510 test_id: test.into(),
1511 test_key: stable_id("python-test", &[worker, test]),
1512 retry,
1513 attempt_id: stable_id("python-attempt", &[run, worker, test, &retry.to_string()]),
1514 }
1515}
1516
1517struct ManifestIndex<'a> {
1518 points: crate::interned::FastSet<&'a str>,
1519 alternatives: crate::interned::FastSet<&'a str>,
1520 decisions: crate::interned::FastMap<&'a str, &'a DecisionMeta>,
1521 lines: BTreeMap<&'a str, (&'a str, usize)>,
1522 files: BTreeMap<&'a str, Vec<&'a str>>,
1523}
1524
1525impl<'a> ManifestIndex<'a> {
1526 fn new(manifest: &'a CoverageManifest) -> Self {
1527 let mut lines = BTreeMap::new();
1528 for point in &manifest.points {
1529 lines.insert(point.id.as_str(), (point.file.as_str(), point.line));
1530 }
1531 for decision in &manifest.decisions {
1532 lines.insert(
1533 decision.id.as_str(),
1534 (decision.file.as_str(), decision.line),
1535 );
1536 }
1537 for branch in &manifest.branches {
1538 lines.insert(branch.id.as_str(), (branch.file.as_str(), branch.line));
1539 }
1540 let mut files = BTreeMap::<&str, Vec<&str>>::new();
1541 for (id, (file, _)) in &lines {
1542 files.entry(*file).or_default().push(*id);
1543 }
1544 Self {
1545 points: manifest
1546 .points
1547 .iter()
1548 .map(|point| point.id.as_str())
1549 .collect(),
1550 alternatives: manifest
1551 .branches
1552 .iter()
1553 .flat_map(|branch| branch.alternatives.iter().map(|alt| alt.id.as_str()))
1554 .collect(),
1555 decisions: manifest
1556 .decisions
1557 .iter()
1558 .map(|decision| (decision.id.as_str(), decision))
1559 .collect(),
1560 lines,
1561 files,
1562 }
1563 }
1564}
1565
1566fn snapshot(
1567 index: &ManifestIndex<'_>,
1568 observations: &Observations,
1569 slot_ids: &[String],
1570 phase: &str,
1571) -> Result<RuntimeSnapshot, PythonEvidenceError> {
1572 let mut positions = observations.slot_hits.clone();
1573 positions.sort_unstable();
1574 positions.dedup();
1575 let mut hits = BTreeSet::new();
1576 for id in observations.hits.iter().map(String::as_str).chain(
1577 positions
1578 .iter()
1579 .map(|position| slot_ids[*position as usize].as_str()),
1580 ) {
1581 if !index.points.contains(id) && !index.alternatives.contains(id) {
1582 return Err(PythonEvidenceError::UnknownObligation(id.to_owned()));
1583 }
1584 hits.insert(id);
1585 }
1586 let hits = hits.into_iter().map(str::to_owned).collect::<BTreeSet<_>>();
1587 let mut decisions = Vec::new();
1588 for (id, vectors) in &observations.vectors {
1589 let Some(meta) = index.decisions.get(id.as_str()) else {
1590 return Err(PythonEvidenceError::UnknownObligation(id.clone()));
1591 };
1592 let mut observed = Vec::new();
1593 for (values, outcome) in vectors {
1594 if values.len() != meta.conditions.len() {
1595 return Err(PythonEvidenceError::InvalidVector {
1596 id: id.clone(),
1597 expected: meta.conditions.len(),
1598 actual: values.len(),
1599 });
1600 }
1601 observed.push(McdcVector {
1602 values: values.clone(),
1603 outcome: *outcome,
1604 });
1605 }
1606 decisions.push(DecisionSnapshot {
1607 meta: (*meta).clone(),
1608 vectors: observed,
1609 });
1610 }
1611 Ok(RuntimeSnapshot {
1614 decisions,
1615 hits: hits.into_iter().collect(),
1616 events: Vec::new(),
1617 logicals: Vec::new(),
1618 phase_id: Some(phase.into()),
1619 })
1620}
1621
1622fn fold_outcome(
1631 outcomes: &mut Vec<(String, String, bool)>,
1632 phase: String,
1633 outcome: String,
1634 xfail: bool,
1635) {
1636 let Some(reported) = outcomes.iter_mut().find(|(name, _, _)| *name == phase) else {
1637 outcomes.push((phase, outcome, xfail));
1638 return;
1639 };
1640 if outcome_severity(&outcome) > outcome_severity(&reported.1) {
1641 reported.1 = outcome;
1642 }
1643 reported.2 |= xfail;
1644}
1645
1646fn outcome_severity(outcome: &str) -> u8 {
1648 match outcome {
1649 "failed" | "rerun" | "error" => 2,
1650 "skipped" => 1,
1651 _ => 0,
1652 }
1653}
1654
1655fn attempt_status(outcomes: &[(String, String, bool)]) -> String {
1656 if outcomes
1657 .iter()
1658 .any(|(_, outcome, _)| matches!(outcome.as_str(), "failed" | "rerun" | "error"))
1659 {
1660 "failed"
1661 } else if outcomes.iter().any(|(_, outcome, _)| outcome == "skipped") {
1662 "skipped"
1663 } else {
1664 "passed"
1665 }
1666 .into()
1667}
1668
1669#[derive(Debug, Clone, PartialEq)]
1670pub struct PythonFrontendRun {
1671 pub declaration: FrontendRunDeclaration,
1672 pub request: CoverageReportRequest,
1673 pub tests: usize,
1674 pub interpreters: usize,
1675 pub python_versions: Vec<String>,
1676}
1677
1678impl PythonFrontendRun {
1679 pub fn archive_entries(&self) -> Result<Vec<EvidenceArchiveEntry>, serde_json::Error> {
1680 let model = PersistedCoverageModel::from_declaration(
1681 self.request
1682 .coverage_model
1683 .as_ref()
1684 .expect("Python frontend always declares a coverage model"),
1685 )
1686 .expect("Python coverage model is contract-valid");
1687 let mut entries = vec![
1688 EvidenceArchiveEntry {
1689 path: "coverage-model.json".into(),
1690 contents: serde_json::to_vec(&model)?,
1691 },
1692 EvidenceArchiveEntry {
1693 path: "frontend.json".into(),
1694 contents: serde_json::to_vec(&self.declaration)?,
1695 },
1696 EvidenceArchiveEntry {
1697 path: "manifest.json".into(),
1698 contents: serde_json::to_vec(&self.request.manifest)?,
1699 },
1700 ];
1701 for (index, result) in self.request.raw_results.iter().enumerate() {
1702 entries.push(EvidenceArchiveEntry {
1703 path: format!("results/{index:08}/mcdc.json"),
1704 contents: serde_json::to_vec(result)?,
1705 });
1706 }
1707 Ok(entries)
1708 }
1709}
1710
1711pub fn build_python_frontend_run(
1714 manifest: &CoverageManifest,
1715 evidence_directory: &Path,
1716 run_id: &str,
1717 generated_at: &str,
1718 test_exit_code: i32,
1719 root: &Path,
1720) -> Result<PythonFrontendRun, PythonEvidenceError> {
1721 let mut evidence = read_evidence_directory(evidence_directory, run_id)?;
1722 let order = crate::served_tests::InProcess::new(&evidence.phase_starts);
1726 let by_time = credit_requests_by_time(&mut evidence, |_, transport, _, seq| {
1727 order.owner(transport, seq).cloned()
1728 });
1729 let mut run = build_from_evidence(
1730 evidence,
1731 manifest,
1732 run_id,
1733 generated_at,
1734 test_exit_code,
1735 root,
1736 )?;
1737 mark_by_time(&mut run, &by_time);
1738 Ok(run)
1739}
1740
1741pub use crate::served_tests::ServedTest;
1742
1743pub fn build_python_server_run(
1747 manifest: &CoverageManifest,
1748 evidence_directory: &Path,
1749 run_id: &str,
1750 generated_at: &str,
1751 test_exit_code: i32,
1752 root: &Path,
1753 served: &[ServedTest],
1754) -> Result<PythonFrontendRun, PythonEvidenceError> {
1755 let mut evidence = read_evidence_directory(evidence_directory, run_id)?;
1756 let mut by_attempt = BTreeMap::new();
1757 for test in served {
1758 let key = (test.worker.clone(), test.test_id.clone(), test.retry);
1759 let outcome = match test.status.as_str() {
1760 "passed" => "passed",
1761 "skipped" => "skipped",
1762 _ => "failed",
1763 };
1764 evidence.outcomes.entry(key.clone()).or_default().push((
1765 "call".into(),
1766 outcome.into(),
1767 test.expected_status == "failed",
1768 ));
1769 evidence
1770 .runners
1771 .insert(key.clone(), test.provenance.runner.clone());
1772 by_attempt.insert(key, test);
1773 }
1774 let by_time = credit_requests_by_time(&mut evidence, |evidence, transport, at, _| {
1775 crate::served_tests::owner(served, evidence.served_by.get(transport), at)
1776 .map(|test| (test.worker.clone(), test.test_id.clone(), test.retry))
1777 });
1778 let mut run = build_from_evidence(
1779 evidence,
1780 manifest,
1781 run_id,
1782 generated_at,
1783 test_exit_code,
1784 root,
1785 )?;
1786 for raw in &mut run.request.raw_results {
1787 let Some(test) = raw.scope.as_ref().and_then(|scope| {
1788 by_attempt.get(&(
1789 scope.worker_id.clone(),
1790 raw.test.clone(),
1791 raw.retry.unwrap_or(0),
1792 ))
1793 }) else {
1794 continue;
1795 };
1796 raw.test = test.name.clone();
1797 raw.title = Some(test.title.clone());
1798 raw.test_file = Some(test.file.clone());
1799 raw.status = Some(test.status.clone());
1800 raw.expected_status = Some(test.expected_status.clone());
1801 raw.provenance = test.provenance.clone();
1802 if by_time.contains(&(test.worker.clone(), test.test_id.clone(), test.retry)) {
1803 raw.attribution = crate::coverage_report::ATTRIBUTION_BY_TIME.into();
1804 }
1805 }
1806 run.tests = run
1807 .request
1808 .raw_results
1809 .iter()
1810 .filter(|raw| raw.role == "test")
1811 .map(|raw| raw.test.as_str())
1812 .collect::<BTreeSet<_>>()
1813 .len();
1814 Ok(run)
1815}
1816
1817fn mark_by_time(run: &mut PythonFrontendRun, by_time: &BTreeSet<(String, String, usize)>) {
1819 for raw in &mut run.request.raw_results {
1820 let Some(scope) = &raw.scope else { continue };
1821 let key = (
1822 scope.worker_id.clone(),
1823 scope.test_id.clone(),
1824 raw.retry.unwrap_or(0),
1825 );
1826 if by_time.contains(&key) && raw.attribution == crate::coverage_report::ATTRIBUTION_EXACT {
1827 raw.attribution = crate::coverage_report::ATTRIBUTION_BY_TIME.into();
1828 }
1829 }
1830}
1831
1832fn credit_requests_by_time(
1837 evidence: &mut Evidence,
1838 owner: impl Fn(&Evidence, &str, i64, usize) -> Option<(String, String, usize)>,
1839) -> BTreeSet<(String, String, usize)> {
1840 let requests = std::mem::take(&mut evidence.requests);
1841 let owners = requests
1842 .iter()
1843 .map(|(_, at, seq, transport, _)| owner(evidence, transport, *at, *seq))
1844 .collect::<Vec<_>>();
1845 let mut credited = BTreeSet::new();
1846 for ((identity, _, _, _, process_worker), owner) in requests.into_iter().zip(owners) {
1847 let Some(observations) = evidence.per_identity.remove(&identity) else {
1848 continue;
1849 };
1850 let into = match owner {
1851 Some((worker, test, retry)) => {
1852 credited.insert((worker.clone(), test.clone(), retry));
1853 evidence
1854 .per_identity
1855 .entry(Identity {
1856 worker,
1857 test,
1858 retry,
1859 phase: "call".into(),
1860 })
1861 .or_default()
1862 }
1863 None => evidence.background.entry(process_worker).or_default(),
1864 };
1865 into.hits.extend(observations.hits);
1866 into.slot_hits.extend(observations.slot_hits);
1867 for (id, vectors) in observations.vectors {
1868 into.vectors.entry(id).or_default().extend(vectors);
1869 }
1870 }
1871 credited
1872}
1873
1874fn build_from_evidence(
1875 evidence: Evidence,
1876 manifest: &CoverageManifest,
1877 run_id: &str,
1878 generated_at: &str,
1879 test_exit_code: i32,
1880 root: &Path,
1881) -> Result<PythonFrontendRun, PythonEvidenceError> {
1882 if evidence.interpreters == 0 {
1883 return Err(PythonEvidenceError::NoInterpreter);
1884 }
1885 if evidence.outcomes.is_empty() {
1886 return Err(PythonEvidenceError::NoTests);
1887 }
1888 let Evidence {
1889 interpreters,
1890 python_versions,
1891 per_identity,
1892 background,
1893 outcomes,
1894 runners,
1895 test_files,
1896 limitations,
1897 slot_ids,
1898 ..
1899 } = evidence;
1900 let mut manifest = manifest.clone();
1901 let index = ManifestIndex::new(&manifest);
1902
1903 let mut raw_results = Vec::new();
1904 let mut observed_runners = BTreeSet::new();
1905 let mut identities_by_attempt =
1906 BTreeMap::<(String, String, usize), Vec<(&Identity, &Observations)>>::new();
1907 for (identity, observations) in &per_identity {
1908 identities_by_attempt
1909 .entry((
1910 identity.worker.clone(),
1911 identity.test.clone(),
1912 identity.retry,
1913 ))
1914 .or_default()
1915 .push((identity, observations));
1916 }
1917 let phases_observed = per_identity.iter().collect::<Vec<_>>();
1920 let mut snapshots = phases_observed
1921 .iter()
1922 .map(|(identity, _)| *identity)
1923 .zip(parallel_map(
1924 &phases_observed,
1925 |(identity, observations)| {
1926 snapshot(&index, observations, &slot_ids, &phase_id(run_id, identity))
1927 },
1928 ))
1929 .collect::<BTreeMap<_, _>>();
1930 let mut take = |identity: &Identity| {
1931 snapshots
1932 .remove(identity)
1933 .expect("each phase's snapshot is taken once")
1934 };
1935 for ((worker, test, retry), mut outcomes) in outcomes {
1936 let runner = runners
1937 .get(&(worker.clone(), test.clone(), retry))
1938 .cloned()
1939 .unwrap_or_else(default_runner);
1940 let attempt_identities = identities_by_attempt
1941 .remove(&(worker.clone(), test.clone(), retry))
1942 .unwrap_or_default();
1943 observed_runners.insert(runner.clone());
1944 outcomes.sort_by_key(|(phase, _, _)| match phase.as_str() {
1945 "setup" => 0,
1946 "call" => 1,
1947 _ => 2,
1948 });
1949 let mut phases = Vec::new();
1950 let mut runtime = Vec::new();
1951 let mut observed_phases = BTreeSet::new();
1952 for (position, (phase_name, outcome, xfail)) in outcomes.iter().enumerate() {
1953 observed_phases.insert(phase_name.clone());
1954 let identity = Identity {
1955 worker: worker.clone(),
1956 test: test.clone(),
1957 retry,
1958 phase: phase_name.clone(),
1959 };
1960 let id = phase_id(run_id, &identity);
1961 phases.push(CoveragePhase {
1962 id: id.clone(),
1963 kind: match phase_name.as_str() {
1964 "call" => "test",
1965 value => value,
1966 }
1967 .into(),
1968 operation: format!("{runner} {phase_name}"),
1969 source: Some(test.clone()),
1970 caused_by_phase_id: None,
1971 started_at_ms: position as i64 * 2 + 1,
1972 ended_at_ms: Some(position as i64 * 2 + 2),
1973 status: Some(match outcome.as_str() {
1974 "rerun" | "error" => "failed".into(),
1975 value => value.into(),
1976 }),
1977 error: None,
1978 });
1979 if let Some((observed, _)) = attempt_identities
1980 .iter()
1981 .find(|(candidate, _)| candidate.phase == phase_name.as_str())
1982 {
1983 runtime.push(take(observed)?);
1984 }
1985 if phase_name != "call" {
1986 continue;
1987 }
1988 if let Some((identity, _)) = attempt_identities
1992 .iter()
1993 .find(|(candidate, _)| candidate.phase == "assertion")
1994 {
1995 observed_phases.insert("assertion".to_owned());
1996 let id = phase_id(run_id, identity);
1997 phases.push(CoveragePhase {
1998 id: id.clone(),
1999 kind: "assertion".into(),
2000 operation: format!("{runner} assertion"),
2001 source: Some(test.clone()),
2002 caused_by_phase_id: None,
2003 started_at_ms: position as i64 * 2 + 1,
2004 ended_at_ms: Some(position as i64 * 2 + 2),
2005 status: Some(
2006 if outcome == "passed" && !*xfail {
2007 "passed"
2008 } else {
2009 "failed"
2010 }
2011 .into(),
2012 ),
2013 error: None,
2014 });
2015 runtime.push(take(identity)?);
2016 }
2017 }
2018 for (identity, _) in attempt_identities {
2021 if !observed_phases.contains(&identity.phase) {
2022 let id = phase_id(run_id, identity);
2023 phases.push(CoveragePhase {
2024 id: id.clone(),
2025 kind: match identity.phase.as_str() {
2026 "call" => "test",
2027 value => value,
2028 }
2029 .into(),
2030 operation: format!("{runner} {}", identity.phase),
2031 source: Some(test.clone()),
2032 caused_by_phase_id: None,
2033 started_at_ms: phases.len() as i64 * 2 + 1,
2034 ended_at_ms: None,
2035 status: Some("failed".into()),
2036 error: Some("the phase started but the runner reported no outcome".into()),
2037 });
2038 runtime.push(take(identity)?);
2039 }
2040 }
2041 let status = if phases.iter().any(|phase| phase.error.is_some()) {
2042 "failed".into()
2043 } else {
2044 attempt_status(&outcomes)
2045 };
2046 let test_file = test_files
2047 .get(&(worker.clone(), test.clone(), retry))
2048 .and_then(|path| relative(root, path))
2049 .or_else(|| test.split("::").next().map(str::to_owned));
2050 raw_results.push(RawTestResult {
2051 test_id: Some(test.clone()),
2052 scope: Some(scope(run_id, &worker, &test, retry)),
2053 test: test.clone(),
2054 test_file: test_file.clone(),
2058 title: test.rsplit("::").next().map(str::to_owned),
2059 retry: Some(retry),
2060 status: Some(status),
2061 expected_status: Some(
2067 if outcomes
2068 .iter()
2069 .any(|(_, outcome, xfail)| *xfail && outcome != "passed")
2070 {
2071 "failed"
2072 } else {
2073 "passed"
2074 }
2075 .into(),
2076 ),
2077 flaky: false,
2078 provenance: TestProvenance {
2079 runner: runner.clone(),
2080 kind: crate::test_kind::from_path(test_file.as_deref()).into(),
2081 project: None,
2082 source: PYTHON_FRONTEND_VERSION.into(),
2083 },
2084 role: "test".into(),
2085 attribution: crate::coverage_report::ATTRIBUTION_EXACT.into(),
2086 phases,
2087 runtime,
2088 browser: Vec::new(),
2089 server: Vec::new(),
2090 });
2091 }
2092 let default_observed = observed_runners
2095 .iter()
2096 .next()
2097 .cloned()
2098 .unwrap_or_else(default_runner);
2099 for ((worker, test, retry), identities) in identities_by_attempt {
2100 let runner = default_observed.clone();
2101 let mut phases = Vec::new();
2102 let mut runtime = Vec::new();
2103 for (position, (identity, _)) in identities.iter().enumerate() {
2104 let id = phase_id(run_id, identity);
2105 phases.push(CoveragePhase {
2106 id: id.clone(),
2107 kind: match identity.phase.as_str() {
2108 "call" => "test",
2109 value => value,
2110 }
2111 .into(),
2112 operation: format!("{runner} {}", identity.phase),
2113 source: Some(test.clone()),
2114 caused_by_phase_id: None,
2115 started_at_ms: position as i64 * 2 + 1,
2116 ended_at_ms: None,
2117 status: Some("failed".into()),
2118 error: Some("the phase started but the runner reported no outcome".into()),
2119 });
2120 runtime.push(take(identity)?);
2121 }
2122 let test_file = test_files
2123 .get(&(worker.clone(), test.clone(), retry))
2124 .and_then(|path| relative(root, path))
2125 .or_else(|| test.split("::").next().map(str::to_owned));
2126 raw_results.push(RawTestResult {
2127 test_id: Some(test.clone()),
2128 scope: Some(scope(run_id, &worker, &test, retry)),
2129 test: test.clone(),
2130 test_file: test_file.clone(),
2134 title: test.rsplit("::").next().map(str::to_owned),
2135 retry: Some(retry),
2136 status: Some("failed".into()),
2137 expected_status: Some("passed".into()),
2138 flaky: false,
2139 provenance: TestProvenance {
2140 runner: runner.clone(),
2141 kind: crate::test_kind::from_path(test_file.as_deref()).into(),
2142 project: None,
2143 source: PYTHON_FRONTEND_VERSION.into(),
2144 },
2145 role: "test".into(),
2146 attribution: crate::coverage_report::ATTRIBUTION_EXACT.into(),
2147 phases,
2148 runtime,
2149 browser: Vec::new(),
2150 server: Vec::new(),
2151 });
2152 }
2153 for (worker, observations) in &background {
2154 if observations.is_empty() {
2155 continue;
2156 }
2157 let test = format!("__supercov_background__:{worker}");
2158 let identity = Identity {
2159 worker: worker.clone(),
2160 test: test.clone(),
2161 retry: 0,
2162 phase: "background".into(),
2163 };
2164 let phase = phase_id(run_id, &identity);
2165 raw_results.push(RawTestResult {
2166 test_id: Some(test.clone()),
2167 scope: Some(scope(run_id, worker, &test, 0)),
2168 test: "Python import, collection and background execution".into(),
2169 test_file: None,
2170 title: None,
2171 retry: Some(0),
2172 status: Some("unknown".into()),
2173 expected_status: None,
2174 flaky: false,
2175 provenance: TestProvenance {
2176 runner: default_observed.clone(),
2177 kind: "unit".into(),
2178 project: None,
2179 source: PYTHON_FRONTEND_VERSION.into(),
2180 },
2181 role: "background".into(),
2182 attribution: crate::coverage_report::ATTRIBUTION_EXACT.into(),
2183 phases: vec![CoveragePhase {
2184 id: phase.clone(),
2185 kind: "background".into(),
2186 operation: "Python import and collection background".into(),
2187 source: None,
2188 caused_by_phase_id: None,
2189 started_at_ms: 0,
2190 ended_at_ms: Some(0),
2191 status: Some("passed".into()),
2192 error: None,
2193 }],
2194 runtime: vec![snapshot(&index, observations, &slot_ids, &phase)?],
2195 browser: Vec::new(),
2196 server: Vec::new(),
2197 });
2198 }
2199
2200 let mut limitation_ids = manifest
2204 .limitations
2205 .iter()
2206 .filter_map(|item| item.get("id").and_then(serde_json::Value::as_str))
2207 .map(str::to_owned)
2208 .collect::<BTreeSet<_>>();
2209 let mut unmeasured = manifest.unmeasured.iter().cloned().collect::<BTreeSet<_>>();
2210 let mut new_limitations = Vec::new();
2211 let observed = raw_results
2214 .iter()
2215 .flat_map(|raw| raw.runtime.iter())
2216 .flat_map(|snapshot| snapshot.hits.iter().cloned())
2217 .collect::<std::collections::HashSet<String>>();
2218 for limitation in limitations.values() {
2219 if let Some(obligation) = &limitation.obligation {
2220 if !index.lines.contains_key(obligation.as_str()) {
2221 return Err(PythonEvidenceError::UnknownObligation(obligation.clone()));
2222 }
2223 unmeasured.insert(obligation.clone());
2224 } else if let Some(file) = &limitation.file {
2225 let observed_elsewhere = limitation.id == "python-probes-unobserved-module"
2239 && index
2240 .files
2241 .get(file.as_str())
2242 .is_some_and(|obligations| obligations.iter().any(|id| observed.contains(*id)));
2243 if !observed_elsewhere && let Some(obligations) = index.files.get(file.as_str()) {
2244 unmeasured.extend(obligations.iter().map(|id| (*id).to_owned()));
2245 }
2246 }
2247 let id = if limitation.file.is_some() || limitation.obligation.is_some() {
2248 stable_id(
2249 &limitation.id,
2250 &[
2251 limitation.file.as_deref().unwrap_or_default(),
2252 limitation.obligation.as_deref().unwrap_or_default(),
2253 ],
2254 )
2255 } else {
2256 limitation.id.clone()
2257 };
2258 if limitation_ids.insert(id.clone()) {
2259 let (file, line) = limitation
2260 .obligation
2261 .as_deref()
2262 .and_then(|id| {
2263 index
2264 .lines
2265 .get(id)
2266 .map(|(file, line)| ((*file).to_owned(), *line))
2267 })
2268 .unwrap_or_else(|| {
2269 (
2270 limitation.file.clone().unwrap_or_else(|| {
2271 manifest
2272 .points
2273 .first()
2274 .map_or(".".into(), |point| point.file.clone())
2275 }),
2276 1,
2277 )
2278 });
2279 new_limitations.push(json!({
2280 "id": id,
2281 "code": limitation.id,
2282 "kind": "semantic-safety",
2283 "file": file,
2284 "line": line,
2285 "column": 0,
2286 "source": "",
2287 "reason": limitation.reason
2288 }));
2289 }
2290 }
2291 manifest.limitations.extend(new_limitations);
2292 manifest.unmeasured = unmeasured.into_iter().collect();
2293 let structural_limitations = limitation_ids.into_iter().collect::<Vec<_>>();
2294
2295 let tests = raw_results
2299 .iter()
2300 .filter(|raw| raw.role == "test")
2301 .map(|raw| raw.test.as_str())
2302 .collect::<BTreeSet<_>>()
2303 .len();
2304 Ok(PythonFrontendRun {
2305 declaration: FrontendRunDeclaration {
2306 protocol_version: LANGUAGE_FRONTEND_PROTOCOL_VERSION,
2307 frontend_id: "python".into(),
2308 frontend_version: PYTHON_FRONTEND_VERSION.into(),
2309 language: "python".into(),
2310 structural_source: StructuralSource::OwnedProbes,
2311 runners: observed_runners
2312 .iter()
2313 .map(|runner| FrontendRunnerDeclaration {
2314 runner: runner.clone(),
2315 execution_model: if runner == UNITTEST_RUNNER {
2316 ExecutionModel::SerialInProcess
2317 } else {
2318 ExecutionModel::ParallelContextPropagated
2319 },
2320 attribution: FrontendAttribution {
2321 run: AttributionPrecision::Exact,
2322 worker: AttributionPrecision::Exact,
2323 test: AttributionPrecision::Exact,
2324 retry: AttributionPrecision::Exact,
2325 phase: AttributionPrecision::Exact,
2326 action: AttributionPrecision::Unavailable,
2327 assertion: if python_runner(runner) {
2330 AttributionPrecision::Exact
2331 } else {
2332 AttributionPrecision::Unavailable
2333 },
2334 },
2335 limitations: if python_runner(runner) {
2336 vec![FrontendLimitation {
2337 id: format!("python-{runner}-action-linkage"),
2338 scopes: vec![FrontendLimitationScope::Action],
2339 reason: format!("{runner} exposes no general action lifecycle"),
2340 }]
2341 } else {
2342 vec![FrontendLimitation {
2343 id: format!("python-server-{runner}-linkage"),
2344 scopes: vec![
2345 FrontendLimitationScope::Action,
2346 FrontendLimitationScope::Assertion,
2347 ],
2348 reason: format!(
2349 "the server sees each {runner} test's requests, not its actions or assertions"
2350 ),
2351 }]
2352 },
2353 })
2354 .collect(),
2355 structural_limitations,
2356 },
2357 request: CoverageReportRequest {
2358 run_id: run_id.into(),
2359 manifest,
2360 raw_results,
2361 generated_at: generated_at.into(),
2362 coverage_model: Some(python_coverage_model()),
2363 integrity: None,
2364 test_exit_code: ExitCodeInput::Present(Some(test_exit_code)),
2365 },
2366 tests,
2367 interpreters,
2368 python_versions: python_versions.into_iter().collect(),
2369 })
2370}
2371
2372#[cfg(test)]
2373mod tests {
2374 use std::time::{SystemTime, UNIX_EPOCH};
2375
2376 use super::*;
2377 use crate::{
2378 frontend_protocol::validate_frontend_report_request,
2379 python_instrumenter::build_python_obligations,
2380 };
2381
2382 #[test]
2386 fn a_request_naming_no_test_is_the_only_running_tests() {
2387 let identity = |worker: &str, test: &str| Identity {
2388 worker: worker.into(),
2389 test: test.into(),
2390 retry: 0,
2391 phase: "call".into(),
2392 };
2393 let observed = |hit: &str| Observations {
2394 hits: BTreeSet::from([hit.to_owned()]),
2395 ..Observations::default()
2396 };
2397 let served = |worker: &str, test: &str, window: (i64, i64)| ServedTest {
2398 worker: worker.into(),
2399 test_id: test.into(),
2400 retry: 0,
2401 name: test.into(),
2402 title: test.into(),
2403 file: "tests/a.spec.js".into(),
2404 status: "passed".into(),
2405 expected_status: "passed".into(),
2406 provenance: TestProvenance::default(),
2407 window: Some(window),
2408 };
2409 let tests = [
2412 served("playwright:w0", "one", (100, 200)),
2413 served("playwright:w1", "two", (100, 200)),
2414 served("playwright:w1", "three", (300, 400)),
2415 ];
2416 let first = identity("supercov-request:a.mmap", "1");
2417 let late = identity("supercov-request:a.mmap", "2");
2418 let mocked = identity("supercov-request:b.mmap", "1");
2419 let alone = identity("supercov-request:b.mmap", "2");
2420 let mut evidence = Evidence {
2421 per_identity: BTreeMap::from([
2422 (first.clone(), observed("first")),
2423 (late.clone(), observed("late")),
2424 (mocked.clone(), observed("mocked")),
2425 (alone.clone(), observed("alone")),
2426 ]),
2427 requests: vec![
2428 (first, 150, 1, "a.mmap".into(), "main".into()),
2429 (late, 250, 2, "a.mmap".into(), "main".into()),
2430 (mocked, 150, 1, "b.mmap".into(), "main".into()),
2431 (alone, 350, 2, "b.mmap".into(), "main".into()),
2432 ],
2433 served_by: BTreeMap::from([(
2434 "a.mmap".to_owned(),
2435 BTreeSet::from([("playwright:w0".to_owned(), "one".to_owned(), 0)]),
2436 )]),
2437 ..Evidence::default()
2438 };
2439 let credited = credit_requests_by_time(&mut evidence, |evidence, transport, at, _| {
2440 crate::served_tests::owner(&tests, evidence.served_by.get(transport), at)
2441 .map(|test| (test.worker.clone(), test.test_id.clone(), test.retry))
2442 });
2443 assert_eq!(
2444 credited,
2445 BTreeSet::from([
2446 ("playwright:w0".to_owned(), "one".to_owned(), 0),
2447 ("playwright:w1".to_owned(), "three".to_owned(), 0),
2448 ])
2449 );
2450 let hits = |worker: &str, test: &str| {
2451 evidence
2452 .per_identity
2453 .get(&identity(worker, test))
2454 .map(|observations| observations.hits.clone())
2455 .unwrap_or_default()
2456 };
2457 assert_eq!(
2459 hits("playwright:w0", "one"),
2460 BTreeSet::from(["first".to_owned()])
2461 );
2462 assert_eq!(
2463 hits("playwright:w1", "three"),
2464 BTreeSet::from(["alone".to_owned()])
2465 );
2466 assert_eq!(
2469 evidence.background["main"].hits,
2470 BTreeSet::from(["late".to_owned(), "mocked".to_owned()])
2471 );
2472 assert!(
2473 evidence
2474 .per_identity
2475 .keys()
2476 .all(|identity| !identity.worker.starts_with(REQUEST_WORKER))
2477 );
2478 }
2479
2480 fn temporary(name: &str) -> std::path::PathBuf {
2481 let nonce = SystemTime::now()
2482 .duration_since(UNIX_EPOCH)
2483 .unwrap()
2484 .as_nanos();
2485 let path = std::env::temp_dir().join(format!(
2486 "supercov-python-evidence-{}-{nonce}-{name}",
2487 std::process::id()
2488 ));
2489 fs::create_dir_all(&path).unwrap();
2490 path
2491 }
2492
2493 fn transport_bytes(payloads: &[&[u8]]) -> Vec<u8> {
2495 let capacity = payloads
2496 .iter()
2497 .fold(TRANSPORT_HEADER_SIZE, |cursor, payload| {
2498 align_transport(cursor + TRANSPORT_RECORD_HEADER_SIZE + payload.len()).unwrap()
2499 });
2500 let mut bytes = vec![0_u8; capacity];
2501 bytes[..8].copy_from_slice(TRANSPORT_MAGIC);
2502 bytes[8..12].copy_from_slice(&TRANSPORT_VERSION.to_le_bytes());
2503 bytes[12..16].copy_from_slice(&(TRANSPORT_HEADER_SIZE as u32).to_le_bytes());
2504 bytes[16..24].copy_from_slice(&(capacity as u64).to_le_bytes());
2505 bytes[32..40].copy_from_slice(&1_u64.to_le_bytes());
2506 let mut cursor = TRANSPORT_HEADER_SIZE;
2507 for payload in payloads {
2508 let start = cursor + TRANSPORT_RECORD_HEADER_SIZE;
2509 bytes[start..start + payload.len()].copy_from_slice(payload);
2510 bytes[cursor + 4..cursor + 8].copy_from_slice(&(payload.len() as u32).to_le_bytes());
2511 bytes[cursor + 8..cursor + 12].copy_from_slice(&crc32fast::hash(payload).to_le_bytes());
2512 bytes[cursor] = 1;
2513 cursor = align_transport(start + payload.len()).unwrap();
2514 }
2515 bytes
2516 }
2517
2518 #[test]
2519 fn a_damaged_transport_is_refused_with_what_is_wrong() {
2520 let exit: &[u8] = br#"{"t":"exit","at":9}"#;
2524 let valid = transport_bytes(&[exit]);
2525 let read = |bytes: &[u8]| match parse_transport("main.1.mmap", bytes, &[], None) {
2526 Ok(_) => "accepted".to_owned(),
2527 Err(error) => error.to_string(),
2528 };
2529 assert_eq!(read(&valid), "accepted");
2530 let set = |at: usize, value: &[u8]| {
2533 let mut bytes = valid.clone();
2534 bytes[at..at + value.len()].copy_from_slice(value);
2535 bytes
2536 };
2537 let mut truncated = valid[..99].to_vec();
2538 truncated[16..24].copy_from_slice(&99_u64.to_le_bytes());
2539 for (bytes, reason) in [
2540 (set(0, b"X"), "header or version does not match"),
2541 (
2542 set(8, &99_u32.to_le_bytes()),
2543 "header or version does not match",
2544 ),
2545 (
2546 set(16, &8_u64.to_le_bytes()),
2547 "declared capacity is outside the mapped file",
2548 ),
2549 (set(24, &3_u64.to_le_bytes()), "3"),
2550 (set(32, &0_u64.to_le_bytes()), "process id is missing"),
2551 (set(64, &[2]), "commit marker or reserved bytes are invalid"),
2552 (
2553 set(68, &0_u32.to_le_bytes()),
2554 "payload length is outside the transport bound",
2555 ),
2556 (
2557 set(68, &64_u32.to_le_bytes()),
2558 "payload extends past the mapped file",
2559 ),
2560 (truncated, "aligned frame extends past the mapped file"),
2561 (set(100, &[1]), "frame padding is not zero"),
2562 (
2563 set(72, &0_u32.to_le_bytes()),
2564 "payload checksum does not match",
2565 ),
2566 (transport_bytes(&[b"{not json"]), "key must be a string"),
2567 (
2568 transport_bytes(&[br#"{"t":"runs","ctx":1,"r":[]}"#]),
2569 "slot runs arrived without the run's slot layout",
2570 ),
2571 ] {
2572 let said = read(&bytes);
2573 assert!(said.contains(reason), "{reason}: {said}");
2574 }
2575 let mut uncommitted = transport_bytes(&[exit, exit]);
2578 uncommitted[104] = 0;
2579 assert_eq!(read(&uncommitted), "accepted");
2580 }
2581
2582 fn write_transport(path: &Path, records: &[serde_json::Value], dropped: u64) {
2583 write_transport_version(path, records, dropped, TRANSPORT_VERSION, None);
2584 }
2585
2586 fn write_transport_version(
2589 path: &Path,
2590 records: &[serde_json::Value],
2591 dropped: u64,
2592 version: u32,
2593 corrupt: Option<usize>,
2594 ) {
2595 let payloads = records
2596 .iter()
2597 .map(|record| serde_json::to_vec(record).unwrap())
2598 .collect::<Vec<_>>();
2599 let capacity = payloads
2600 .iter()
2601 .fold(TRANSPORT_HEADER_SIZE, |cursor, payload| {
2602 align_transport(cursor + TRANSPORT_RECORD_HEADER_SIZE + payload.len()).unwrap()
2603 })
2604 + 64;
2605 let mut bytes = vec![0_u8; capacity];
2606 bytes[..8].copy_from_slice(TRANSPORT_MAGIC);
2607 bytes[8..12].copy_from_slice(&version.to_le_bytes());
2608 bytes[12..16].copy_from_slice(&(TRANSPORT_HEADER_SIZE as u32).to_le_bytes());
2609 bytes[16..24].copy_from_slice(&(capacity as u64).to_le_bytes());
2610 bytes[24..32].copy_from_slice(&dropped.to_le_bytes());
2611 bytes[32..40].copy_from_slice(&1_u64.to_le_bytes());
2612 let mut cursor = TRANSPORT_HEADER_SIZE;
2613 for (index, payload) in payloads.into_iter().enumerate() {
2614 let payload_start = cursor + TRANSPORT_RECORD_HEADER_SIZE;
2615 let payload_end = payload_start + payload.len();
2616 bytes[payload_start..payload_end].copy_from_slice(&payload);
2617 bytes[cursor + 4..cursor + 8].copy_from_slice(&(payload.len() as u32).to_le_bytes());
2618 let expected = if version == TRANSPORT_VERSION_FNV {
2622 payload.iter().fold(0x811c_9dc5_u32, |value, byte| {
2623 (value ^ u32::from(*byte)).wrapping_mul(0x0100_0193)
2624 })
2625 } else {
2626 crc32fast::hash(&payload)
2627 };
2628 let checksum = expected ^ u32::from(corrupt == Some(index));
2629 bytes[cursor + 8..cursor + 12].copy_from_slice(&checksum.to_le_bytes());
2630 bytes[cursor] = 1;
2631 cursor = align_transport(payload_end).unwrap();
2632 }
2633 fs::write(path, bytes).unwrap();
2634 }
2635
2636 fn under(first: &str, rest: &str) -> String {
2641 let mut path = PathBuf::from(if cfg!(windows) {
2642 format!("C:\\{first}")
2643 } else {
2644 format!("/{first}")
2645 });
2646 for part in rest.split('/').filter(|part| !part.is_empty()) {
2647 path.push(part);
2648 }
2649 path.to_string_lossy().into_owned()
2650 }
2651
2652 fn assertion_sources(run: &PythonFrontendRun) -> Vec<String> {
2653 run.request.raw_results[0]
2654 .phases
2655 .iter()
2656 .filter(|phase| phase.kind == "assertion")
2657 .filter_map(|phase| phase.source.clone())
2658 .collect()
2659 }
2660
2661 fn run_with_sites(
2662 name: &str,
2663 sites: &[serde_json::Value],
2664 outcome_file: Option<&str>,
2665 root: &str,
2666 ) -> PythonFrontendRun {
2667 let source = "def f(a):\n return a\n";
2668 let obligations = build_python_obligations("m.py", source).unwrap();
2669 let mut outcome = json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call","outcome":"passed","xfail":false});
2670 if let Some(file) = outcome_file {
2671 outcome["file"] = json!(file);
2672 }
2673 let mut lines = vec![
2674 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"python","argv":["pytest"]}),
2675 json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call"}),
2676 json!({"t":"assert","ctx":1}),
2677 ];
2678 lines.extend(sites.iter().cloned());
2679 lines.push(outcome);
2680 lines.push(json!({"t":"exit","at":9}));
2681 let directory = temporary(name);
2682 write_transport(&directory.join("main.1.mmap"), &lines, 0);
2683 let run = build_python_frontend_run(
2684 &obligations.manifest,
2685 &directory,
2686 "run-1",
2687 "now",
2688 0,
2689 Path::new(root),
2690 )
2691 .unwrap();
2692 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
2693 fs::remove_dir_all(directory).unwrap();
2694 run
2695 }
2696
2697 const SLOT_DIGEST: &str = "0123456789abcdef0123456789abcdef";
2698
2699 fn slot_layout() -> serde_json::Value {
2703 json!({
2704 "version": 2, "digest": SLOT_DIGEST, "header": 16, "bytes": 36,
2705 "ids": ["s1", "s2"],
2706 "decisions": [{
2707 "start": 18, "width": 2, "id": "d",
2708 "outcomeTrue": "d:true", "outcomeFalse": "d:false",
2709 "logical": [{"evaluated": "or:evaluated", "shortCircuit": "or:short",
2710 "previousLeaves": [0], "operandLeaves": [1]}]
2711 }]
2712 })
2713 }
2714
2715 fn slot_bytes(context: u64, tag: Option<&str>, set: &[usize]) -> Vec<u8> {
2717 let mut bytes = vec![0_u8; 36];
2718 bytes[..8].copy_from_slice(&context.to_le_bytes());
2719 if let Some(tag) = tag {
2720 for index in 0..8 {
2721 bytes[8 + index] = u8::from_str_radix(&tag[index * 2..index * 2 + 2], 16).unwrap();
2722 }
2723 }
2724 for index in set {
2725 bytes[*index] = 1;
2726 }
2727 bytes
2728 }
2729
2730 fn killed_process(name: &str, slot: &[u8]) -> PathBuf {
2733 let directory = temporary(name);
2734 write_transport(
2735 &directory.join("main.1.token.mmap"),
2736 &[
2737 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.9.21","executable":"python","argv":["pytest"]}),
2738 json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call"}),
2739 ],
2740 0,
2741 );
2742 fs::write(directory.join("main.1.token.0.slot"), slot).unwrap();
2743 fs::write(
2744 directory.join("layout.json"),
2745 serde_json::to_vec(&slot_layout()).unwrap(),
2746 )
2747 .unwrap();
2748 directory
2749 }
2750
2751 fn call_observations(evidence: &Evidence) -> Observations {
2752 evidence
2753 .per_identity
2754 .iter()
2755 .find(|(identity, _)| identity.phase == "call")
2756 .map(|(_, observations)| Observations {
2757 hits: observations
2759 .hits
2760 .iter()
2761 .cloned()
2762 .chain(
2763 observations
2764 .slot_hits
2765 .iter()
2766 .map(|position| evidence.slot_ids[*position as usize].clone()),
2767 )
2768 .collect(),
2769 slot_hits: Vec::new(),
2770 vectors: observations.vectors.clone(),
2771 })
2772 .unwrap_or_default()
2773 }
2774
2775 #[test]
2776 fn a_killed_process_slot_is_read_as_its_last_records() {
2777 let directory = killed_process(
2780 "py-slot-killed",
2781 &slot_bytes(1, Some(SLOT_DIGEST), &[16, 18 + 15]),
2782 );
2783 let evidence = read_evidence_directory(&directory, "run-1").unwrap();
2784 let call = call_observations(&evidence);
2785 assert_eq!(
2786 call.hits.iter().cloned().collect::<Vec<_>>(),
2787 ["d:true", "or:evaluated", "s1"],
2788 "the obligation, and what the vector implies"
2789 );
2790 assert_eq!(
2791 call.vectors["d"],
2792 BTreeSet::from([(vec![Some(false), Some(true)], true)])
2793 );
2794 fs::remove_dir_all(directory).unwrap();
2795 }
2796
2797 #[test]
2798 fn a_head_byte_is_read_with_the_bytes_it_stands_for() {
2799 let directory = temporary("py-slot-implied");
2803 write_transport(
2804 &directory.join("main.1.token.mmap"),
2805 &[
2806 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.9.21","executable":"python","argv":["pytest"]}),
2807 json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call"}),
2808 json!({"t":"runs","ctx":1,"r":[17, 1]}),
2809 ],
2810 0,
2811 );
2812 let mut layout = slot_layout();
2813 layout["implied"] = json!([[17, [16, 18 + 15]]]);
2814 fs::write(
2815 directory.join("layout.json"),
2816 serde_json::to_vec(&layout).unwrap(),
2817 )
2818 .unwrap();
2819 let evidence = read_evidence_directory(&directory, "run-1").unwrap();
2820 let call = call_observations(&evidence);
2821 assert_eq!(
2822 call.hits.iter().cloned().collect::<Vec<_>>(),
2823 ["d:true", "or:evaluated", "s1", "s2"]
2824 );
2825 assert_eq!(
2826 call.vectors["d"],
2827 BTreeSet::from([(vec![Some(false), Some(true)], true)])
2828 );
2829 for implied in [
2832 json!([[17, [17]]]),
2833 json!([[17, [36]]]),
2834 json!([[17, [16]], [16, [18]]]),
2835 ] {
2836 layout["implied"] = implied;
2837 fs::write(
2838 directory.join("layout.json"),
2839 serde_json::to_vec(&layout).unwrap(),
2840 )
2841 .unwrap();
2842 assert!(read_evidence_directory(&directory, "run-1").is_err());
2843 }
2844 fs::remove_dir_all(directory).unwrap();
2845 }
2846
2847 #[test]
2848 fn a_slot_gone_since_the_listing_is_skipped_and_nothing_else_is() {
2849 let directory = temporary("py-slot-vanished");
2854 let gone = directory.join("main.1.token.0.slot");
2855 assert!(
2856 entry_metadata("main.1.token.0.slot", &gone)
2857 .unwrap()
2858 .is_none()
2859 );
2860 assert!(map_slot(&gone).unwrap().is_none());
2861 assert!(entry_metadata("main.1.token.mmap", &directory.join("main.1.token.mmap")).is_err());
2862 assert!(entry_metadata("layout.json", &directory.join("layout.json")).is_err());
2863 fs::write(&gone, slot_bytes(1, Some(SLOT_DIGEST), &[16])).unwrap();
2864 assert!(
2865 entry_metadata("main.1.token.0.slot", &gone)
2866 .unwrap()
2867 .is_some()
2868 );
2869 assert!(map_slot(&gone).unwrap().is_some());
2870 fs::remove_dir_all(directory).unwrap();
2871 }
2872
2873 #[test]
2874 fn a_slot_its_process_closed_is_not_counted() {
2875 let directory = killed_process("py-slot-closed", &slot_bytes(1, None, &[16]));
2878 let evidence = read_evidence_directory(&directory, "run-1").unwrap();
2879 assert!(call_observations(&evidence).hits.is_empty());
2880 fs::remove_dir_all(directory).unwrap();
2881 }
2882
2883 #[test]
2884 fn a_slot_is_read_only_against_its_own_layout() {
2885 let other = "fedcba9876543210fedcba9876543210";
2886 let directory = killed_process("py-slot-foreign", &slot_bytes(1, Some(other), &[16]));
2887 assert!(matches!(
2888 read_evidence_directory(&directory, "run-1"),
2889 Err(PythonEvidenceError::InvalidTransport { .. })
2890 ));
2891 fs::remove_dir_all(directory).unwrap();
2892
2893 let directory = killed_process("py-slot-byte", &slot_bytes(1, Some(SLOT_DIGEST), &[]));
2895 let mut bytes = slot_bytes(1, Some(SLOT_DIGEST), &[]);
2896 bytes[17] = 7;
2897 fs::write(directory.join("main.1.token.0.slot"), bytes).unwrap();
2898 assert!(read_evidence_directory(&directory, "run-1").is_err());
2899 fs::remove_dir_all(directory).unwrap();
2900 }
2901
2902 #[test]
2903 fn a_slot_needs_its_transport_and_a_partial_layout_is_ignored() {
2904 let directory = killed_process("py-slot-orphan", &slot_bytes(1, Some(SLOT_DIGEST), &[16]));
2905 fs::write(directory.join("layout.json.42.token.partial"), b"{").unwrap();
2907 assert!(read_evidence_directory(&directory, "run-1").is_ok());
2908 fs::write(
2909 directory.join("other.9.token.0.slot"),
2910 slot_bytes(1, Some(SLOT_DIGEST), &[16]),
2911 )
2912 .unwrap();
2913 assert!(matches!(
2914 read_evidence_directory(&directory, "run-1"),
2915 Err(PythonEvidenceError::InvalidTransport { .. })
2916 ));
2917 fs::remove_dir_all(directory).unwrap();
2918 }
2919
2920 #[test]
2921 fn a_test_s_assertions_are_its_one_assertion_phase() {
2922 let run = run_with_sites("py-assertion-phase", &[], None, &under("project", ""));
2925 assert_eq!(
2926 assertion_sources(&run),
2927 vec!["tests/test_m.py::test_a".to_string()]
2928 );
2929 }
2930
2931 #[test]
2932 fn the_runner_names_the_test_file_in_either_path_form() {
2933 let root = under("project", "");
2938 for reported in [
2939 under("project", "tests/test_m.py"),
2940 "tests/test_m.py".to_owned(),
2941 "./tests/test_m.py".to_owned(),
2942 ] {
2943 let run = run_with_sites("py-asite-file", &[], Some(reported.as_str()), &root);
2944 assert_eq!(
2945 run.request.raw_results[0].test_file.as_deref(),
2946 Some("tests/test_m.py"),
2947 "{reported} should resolve to the project path"
2948 );
2949 }
2950 let without = run_with_sites("py-asite-nofile", &[], None, &root);
2951 assert_eq!(
2952 without.request.raw_results[0].test_file.as_deref(),
2953 Some("tests/test_m.py"),
2954 "a pytest node id already begins with the file"
2955 );
2956 }
2957
2958 fn phased_run(name: &str) -> PythonFrontendRun {
2962 let source = "def f(a, b):\n if a and b:\n return 1\n return 2\n";
2963 let obligations = build_python_obligations("m.py", source).unwrap();
2964 let statements = &obligations.plan.statements;
2965 let decision = &obligations.manifest.decisions[0].id;
2966 let test = "tests/test_m.py::test_a";
2967 let lines = [
2968 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"python","argv":["pytest"]}),
2969 json!({"t":"hit","ctx":0,"id":statements[0].id}),
2970 json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":test,"retry":0,"phase":"setup"}),
2971 json!({"t":"hits","ctx":1,"ids":[statements[0].id, statements[1].id]}),
2972 json!({"t":"outcome","worker":"main","test":test,"retry":0,"phase":"setup","outcome":"passed","xfail":false}),
2973 json!({"t":"phase","ctx":2,"at":6,"worker":"main","test":test,"retry":0,"phase":"call"}),
2974 json!({"t":"hit","ctx":2,"id":statements[1].id}),
2975 json!({"t":"dec","ctx":2,"id":decision,"v":"22","o":1}),
2976 json!({"t":"assert","ctx":2}),
2977 json!({"t":"hit","ctx":2,"id":statements[2].id}),
2978 json!({"t":"dec","ctx":2,"id":decision,"v":"21","o":0}),
2979 json!({"t":"outcome","worker":"main","test":test,"retry":0,"phase":"call","outcome":"passed","xfail":false}),
2980 json!({"t":"phase","ctx":3,"at":7,"worker":"main","test":test,"retry":0,"phase":"teardown"}),
2981 json!({"t":"hit","ctx":3,"id":statements[2].id}),
2982 json!({"t":"outcome","worker":"main","test":test,"retry":0,"phase":"teardown","outcome":"passed","xfail":false}),
2983 json!({"t":"exit","at":9}),
2984 ];
2985 let directory = temporary(name);
2986 write_transport(&directory.join("main.1.mmap"), &lines, 0);
2987 let run = build_python_frontend_run(
2988 &obligations.manifest,
2989 &directory,
2990 "run-1",
2991 "now",
2992 0,
2993 Path::new(""),
2994 )
2995 .unwrap();
2996 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
2997 fs::remove_dir_all(directory).unwrap();
2998 run
2999 }
3000
3001 #[test]
3002 fn a_snapshot_that_names_its_phase_analyses_as_the_events_it_replaces() {
3003 use crate::coverage_report::{RuntimeEvent, analyze_coverage_results};
3007 let run = phased_run("py-phase-named");
3008 let named = &run.request;
3009 assert!(
3010 named
3011 .raw_results
3012 .iter()
3013 .flat_map(|raw| &raw.runtime)
3014 .filter(|snapshot| snapshot.phase_id.is_some())
3015 .count()
3016 >= 4,
3017 "setup, call, assertion and teardown each name their phase"
3018 );
3019 let mut events = named.clone();
3020 for snapshot in events
3021 .raw_results
3022 .iter_mut()
3023 .flat_map(|raw| raw.runtime.iter_mut())
3024 {
3025 let Some(phase) = snapshot.phase_id.take() else {
3026 continue;
3027 };
3028 let mut clock = 1;
3029 let mut event = |event_type: &str, id: &str, vector: Option<McdcVector>| {
3030 clock += 1;
3031 RuntimeEvent {
3032 event_type: event_type.into(),
3033 id: id.into(),
3034 vector,
3035 timestamp_ms: clock,
3036 phase_id: Some(phase.clone()),
3037 statement_id: None,
3038 environment: "python".into(),
3039 }
3040 };
3041 let mut written = snapshot
3042 .hits
3043 .iter()
3044 .map(|hit| event("hit", hit, None))
3045 .collect::<Vec<_>>();
3046 for decision in &snapshot.decisions {
3047 for vector in &decision.vectors {
3048 written.push(event("decision", &decision.meta.id, Some(vector.clone())));
3049 }
3050 }
3051 snapshot.events = written;
3052 }
3053 let from_names = analyze_coverage_results(named).unwrap();
3054 let from_events = analyze_coverage_results(&events).unwrap();
3055 assert!(
3056 from_names
3057 .view
3058 .phases
3059 .iter()
3060 .any(|phase| phase.explicit_events > 1),
3061 "the fixture's phases carry explicit evidence"
3062 );
3063 assert_eq!(
3064 serde_json::to_value(&from_names).unwrap(),
3065 serde_json::to_value(&from_events).unwrap()
3066 );
3067 }
3068
3069 #[test]
3070 fn analysing_the_archived_request_is_analysing_the_archive() {
3071 use crate::coverage_report::{
3074 ArchiveReportRequest, ExitCodeInput, TransportStats, analyze_coverage_archive,
3075 analyze_frontend_request,
3076 };
3077 let run = phased_run("py-archived");
3078 let directory = temporary("py-archived-archive");
3079 let archive = directory.join("evidence.raw.gz");
3080 crate::evidence_archive::write_archive(run.archive_entries().unwrap(), &archive).unwrap();
3081 let integrity = json!({"schemaVersion": 2});
3082 let from_archive = analyze_coverage_archive(&ArchiveReportRequest {
3083 archive_path: archive,
3084 run_id: "run-1".into(),
3085 generated_at: "then".into(),
3086 integrity: Some(integrity.clone()),
3087 test_exit_code: ExitCodeInput::Present(Some(0)),
3088 })
3089 .unwrap();
3090 let in_memory = analyze_frontend_request(
3091 &run.declaration,
3092 &CoverageReportRequest {
3093 run_id: "run-1".into(),
3094 generated_at: "then".into(),
3095 integrity: Some(integrity),
3096 test_exit_code: ExitCodeInput::Present(Some(0)),
3097 ..run.request.clone()
3098 },
3099 TransportStats::none(),
3100 )
3101 .unwrap();
3102 fs::remove_dir_all(directory).unwrap();
3103 assert_eq!(
3104 serde_json::to_value(&in_memory).unwrap(),
3105 serde_json::to_value(&from_archive).unwrap()
3106 );
3107 }
3108
3109 #[test]
3110 fn evidence_before_the_first_assertion_links_to_it_when_the_test_passes() {
3111 let source = "def f(a):\n return a\n\n\ndef g(b):\n return b\n";
3116 let obligations = build_python_obligations("m.py", source).unwrap();
3117 let before = &obligations.plan.statements[0].id;
3118 let after = &obligations.plan.statements[1].id;
3119 let run_with = |name: &str, outcome: &str| {
3120 let lines = [
3121 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"python","argv":["pytest"]}),
3122 json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call"}),
3123 json!({"t":"hit","ctx":1,"id":before}),
3124 json!({"t":"assert","ctx":1}),
3125 json!({"t":"assert","ctx":1}),
3126 json!({"t":"hit","ctx":1,"id":after}),
3127 json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call","outcome":outcome,"xfail":false}),
3128 json!({"t":"exit","at":9}),
3129 ];
3130 let directory = temporary(name);
3131 write_transport(&directory.join("main.1.mmap"), &lines, 0);
3132 let run = build_python_frontend_run(
3133 &obligations.manifest,
3134 &directory,
3135 "run-1",
3136 "now",
3137 0,
3138 Path::new(""),
3139 )
3140 .unwrap();
3141 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
3142 fs::remove_dir_all(directory).unwrap();
3143 run
3144 };
3145 let events_of = |result: &RawTestResult, phase: &str| -> BTreeSet<String> {
3146 result
3147 .runtime
3148 .iter()
3149 .filter(|snapshot| snapshot.phase_id.as_deref() == Some(phase))
3150 .flat_map(|snapshot| snapshot.hits.iter().cloned())
3151 .collect()
3152 };
3153
3154 let run = run_with("asserted-passed", "passed");
3155 let passed = &run.request.raw_results[0];
3156 assert_eq!(passed.test, "tests/test_m.py::test_a");
3157 let assertion = passed
3158 .phases
3159 .iter()
3160 .find(|phase| phase.kind == "assertion")
3161 .expect("the asserting test carries an assertion phase");
3162 assert_eq!(assertion.status.as_deref(), Some("passed"));
3163 assert_eq!(
3164 events_of(passed, &assertion.id),
3165 BTreeSet::from([before.clone()]),
3166 "only what ran before the marker is the assertion's evidence"
3167 );
3168 let test_phase = passed
3169 .phases
3170 .iter()
3171 .find(|phase| phase.kind == "test")
3172 .unwrap();
3173 assert_eq!(
3174 events_of(passed, &test_phase.id),
3175 BTreeSet::from([before.clone(), after.clone()]),
3176 "the test phase keeps everything it ran"
3177 );
3178 assert_eq!(
3179 run.declaration.runners[0].attribution.assertion,
3180 AttributionPrecision::Exact
3181 );
3182
3183 let run = run_with("asserted-failed", "failed");
3184 let failed = &run.request.raw_results[0];
3185 let assertion = failed
3186 .phases
3187 .iter()
3188 .find(|phase| phase.kind == "assertion")
3189 .unwrap();
3190 assert_eq!(
3191 assertion.status.as_deref(),
3192 Some("failed"),
3193 "a failed test's assertion witnessed nothing"
3194 );
3195 }
3196
3197 #[test]
3198 fn repeated_runtime_limitations_keep_each_affected_file_once() {
3199 let mut manifest = build_python_obligations("a.py", "x = 1\n")
3200 .unwrap()
3201 .manifest;
3202 let second = build_python_obligations("b.py", "y = 2\n")
3203 .unwrap()
3204 .manifest;
3205 manifest.points.extend(second.points);
3206 let directory = temporary("repeated-limitations");
3207 let mut records = vec![
3208 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.11","executable":"python","argv":[]}),
3209 json!({"t":"outcome","worker":"main","test":"test_a","retry":0,"phase":"call","outcome":"passed","xfail":false}),
3210 ];
3211 for _ in 0..1000 {
3212 for file in ["a.py", "b.py"] {
3213 records.push(json!({"t":"limitation","id":"python-probes-unobserved-module","file":file,"reason":"entry script"}));
3214 }
3215 }
3216 write_transport(&directory.join("main.1.mmap"), &records, 0);
3217 let evidence = read_evidence_directory(&directory, "run-1").unwrap();
3218 assert_eq!(
3219 evidence.limitations.len(),
3220 2,
3221 "deduplicate before manifest expansion"
3222 );
3223 let run =
3224 build_python_frontend_run(&manifest, &directory, "run-1", "now", 0, Path::new(""))
3225 .unwrap();
3226 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
3227 let limitations = &run.request.manifest.limitations;
3228 assert_eq!(limitations.len(), 2);
3229 assert_ne!(limitations[0]["id"], limitations[1]["id"]);
3230 assert_eq!(limitations[0]["file"], "a.py");
3231 assert_eq!(limitations[1]["file"], "b.py");
3232 assert_eq!(limitations[0]["code"], "python-probes-unobserved-module");
3233 assert_eq!(run.request.manifest.unmeasured.len(), 2);
3234 fs::remove_dir_all(directory).unwrap();
3235 }
3236
3237 #[test]
3238 fn a_script_run_unobserved_stays_measured_where_its_imports_were_observed() {
3239 let imported = build_python_obligations("imported.py", "def f():\n return 1\n").unwrap();
3243 let only_run = build_python_obligations("only_run.py", "y = 2\n").unwrap();
3244 let mut manifest = imported.manifest.clone();
3245 manifest.points.extend(only_run.manifest.points.clone());
3246 let statement = &imported.plan.statements[0];
3247 let directory = temporary("script-and-import");
3248 let records = vec![
3249 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.11","executable":"python","argv":["pytest"]}),
3250 json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call"}),
3251 json!({"t":"hit","ctx":1,"id":statement.id}),
3252 json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call","outcome":"passed","xfail":false}),
3253 json!({"t":"limitation","id":"python-probes-unobserved-module","file":"imported.py","reason":"entry script"}),
3254 json!({"t":"limitation","id":"python-probes-unobserved-module","file":"only_run.py","reason":"entry script"}),
3255 ];
3256 write_transport(&directory.join("main.1.mmap"), &records, 0);
3257 let run =
3258 build_python_frontend_run(&manifest, &directory, "run-1", "now", 0, Path::new(""))
3259 .unwrap();
3260 let unmeasured = &run.request.manifest.unmeasured;
3261 let file_points = |file: &str| {
3262 manifest
3263 .points
3264 .iter()
3265 .filter(|point| point.file == file)
3266 .map(|point| point.id.clone())
3267 .collect::<Vec<_>>()
3268 };
3269 assert!(
3270 file_points("imported.py")
3271 .iter()
3272 .all(|id| !unmeasured.contains(id)),
3273 "an observed file keeps its measurement"
3274 );
3275 assert!(
3276 file_points("only_run.py")
3277 .iter()
3278 .all(|id| unmeasured.contains(id)),
3279 "a file nothing observed stays unmeasured"
3280 );
3281 let files = run
3283 .request
3284 .manifest
3285 .limitations
3286 .iter()
3287 .map(|limitation| limitation["file"].as_str().unwrap().to_owned())
3288 .collect::<BTreeSet<_>>();
3289 assert!(files.contains("imported.py") && files.contains("only_run.py"));
3290 fs::remove_dir_all(directory).unwrap();
3291 }
3292
3293 #[test]
3294 fn joins_phases_outcomes_hits_and_vectors_into_exact_results() {
3295 let source = "def f(a, b):\n if a and b:\n return 1\n return 0\n";
3296 let obligations = build_python_obligations("m.py", source).unwrap();
3297 let decision = &obligations.plan.decisions[0];
3298 let statement = &obligations.plan.statements[0];
3299 let directory = temporary("join");
3300 let lines = [
3301 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"python","argv":["pytest"]}),
3302 json!({"t":"hit","ctx":0,"id":statement.id}),
3303 json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call"}),
3304 json!({"t":"dec","ctx":1,"id":decision.id,"v":"22","o":1}),
3305 json!({"t":"hit","ctx":1,"id":decision.outcome_true}),
3306 json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"setup","outcome":"passed","xfail":false}),
3307 json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call","outcome":"passed","xfail":false}),
3308 json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"teardown","outcome":"passed","xfail":false}),
3309 json!({"t":"limitation","id":"python-decision-partially-mapped","reason":"folded","obligation":decision.id}),
3310 json!({"t":"exit","at":9}),
3311 ];
3312 write_transport(&directory.join("main.1.mmap"), &lines, 0);
3313 let run = build_python_frontend_run(
3314 &obligations.manifest,
3315 &directory,
3316 "run-1",
3317 "now",
3318 0,
3319 Path::new(""),
3320 )
3321 .unwrap();
3322 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
3323 assert_eq!(run.tests, 1);
3324 assert_eq!(run.request.raw_results.len(), 2);
3325 let test = &run.request.raw_results[0];
3326 assert_eq!(test.status.as_deref(), Some("passed"));
3327 assert_eq!(test.phases.len(), 3);
3328 assert_eq!(test.runtime.len(), 1);
3329 assert_eq!(test.runtime[0].decisions.len(), 1);
3330 assert!(test.runtime[0].phase_id.is_some());
3331 assert!(test.runtime[0].events.is_empty());
3332 let background = &run.request.raw_results[1];
3333 assert_eq!(background.role, "background");
3334 assert!(run.request.manifest.unmeasured.contains(&decision.id));
3335 assert!(run.declaration.structural_limitations.contains(&stable_id(
3336 "python-decision-partially-mapped",
3337 &["", &decision.id]
3338 )));
3339 fs::remove_dir_all(directory).unwrap();
3340 }
3341
3342 #[test]
3343 fn a_damaged_record_is_refused_with_what_is_wrong() {
3344 let source = "def f(a, b):\n if a and b:\n return 1\n return 0\n";
3348 let obligations = build_python_obligations("m.py", source).unwrap();
3349 let decision = obligations.plan.decisions[0].id.clone();
3350 let process = json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"python","argv":["pytest"]});
3351 let phase = json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"t::a","retry":0,"phase":"call"});
3352 let outcome = json!({"t":"outcome","worker":"main","test":"t::a","retry":0,"phase":"call","outcome":"passed","xfail":false});
3353 let with = |record: &serde_json::Value, key: &str, value: serde_json::Value| {
3354 let mut changed = record.clone();
3355 changed[key] = value;
3356 changed
3357 };
3358 let read = |name: &str, records: Vec<serde_json::Value>| {
3359 let directory = temporary(name);
3360 write_transport(&directory.join("main.1.mmap"), &records, 0);
3361 let said = match build_python_frontend_run(
3362 &obligations.manifest,
3363 &directory,
3364 "run-1",
3365 "now",
3366 0,
3367 Path::new(""),
3368 ) {
3369 Ok(_) => "accepted".to_owned(),
3370 Err(error) => error.to_string(),
3371 };
3372 fs::remove_dir_all(directory).unwrap();
3373 said
3374 };
3375 let exit = json!({"t":"exit","at":9});
3376 assert_eq!(
3377 read(
3378 "record-valid",
3379 vec![
3380 process.clone(),
3381 phase.clone(),
3382 outcome.clone(),
3383 exit.clone()
3384 ]
3385 ),
3386 "accepted"
3387 );
3388 for (name, records, reason) in [
3389 (
3390 "record-version",
3391 vec![with(&process, "v", json!(2))],
3392 "unsupported Python evidence version 2",
3393 ),
3394 (
3395 "record-run",
3396 vec![with(&process, "run", json!("run-0"))],
3397 "belongs to run run-0, expected run-1",
3398 ),
3399 (
3400 "record-pid",
3401 vec![with(&process, "pid", json!(7))],
3402 "does not match the transport owner",
3403 ),
3404 (
3405 "record-python",
3406 vec![with(&process, "python", json!("3.8.1"))],
3407 "the test command ran Python 3.8.1",
3408 ),
3409 (
3410 "record-context",
3411 vec![process.clone(), with(&phase, "ctx", json!(0))],
3412 "phase context 0 is reserved",
3413 ),
3414 (
3415 "record-phase",
3416 vec![process.clone(), with(&phase, "phase", json!("body"))],
3417 "unknown pytest phase",
3418 ),
3419 (
3420 "record-identity",
3421 vec![process.clone(), with(&phase, "test", json!(" "))],
3422 "must name a worker and test",
3423 ),
3424 (
3425 "record-outcome-phase",
3426 vec![process.clone(), with(&outcome, "phase", json!("body"))],
3427 "unknown test outcome phase",
3428 ),
3429 (
3430 "record-outcome",
3431 vec![process.clone(), with(&outcome, "outcome", json!("maybe"))],
3432 "unknown test outcome",
3433 ),
3434 (
3435 "record-runner",
3436 vec![process.clone(), with(&outcome, "runner", json!("nose"))],
3437 "unknown Python test runner",
3438 ),
3439 (
3440 "record-two-runners",
3441 vec![
3442 process.clone(),
3443 outcome.clone(),
3444 with(&outcome, "runner", json!("unittest")),
3445 ],
3446 "one attempt was reported by two runners",
3447 ),
3448 (
3449 "record-digits",
3450 vec![
3451 process.clone(),
3452 phase.clone(),
3453 json!({"t":"dec","ctx":1,"id":decision,"v":"23","o":1}),
3454 ],
3455 "decision vector digits must be 0, 1 or 2",
3456 ),
3457 (
3458 "record-decision-outcome",
3459 vec![
3460 process.clone(),
3461 phase.clone(),
3462 json!({"t":"dec","ctx":1,"id":decision,"v":"22","o":2}),
3463 ],
3464 "decision outcome must be 0 or 1",
3465 ),
3466 (
3467 "record-batch-digits",
3468 vec![
3469 process.clone(),
3470 phase.clone(),
3471 json!({"t":"decs","ctx":1,"v":[[decision, "", 1]]}),
3472 ],
3473 "decision vector digits must be 0, 1 or 2",
3474 ),
3475 (
3476 "record-batch-outcome",
3477 vec![
3478 process.clone(),
3479 phase.clone(),
3480 json!({"t":"decs","ctx":1,"v":[[decision, "21", 3]]}),
3481 ],
3482 "decision outcome must be 0 or 1",
3483 ),
3484 (
3485 "record-unknown-hit",
3486 vec![
3487 process.clone(),
3488 phase.clone(),
3489 json!({"t":"hit","ctx":1,"id":"py:nowhere"}),
3490 outcome.clone(),
3491 exit.clone(),
3492 ],
3493 "unknown obligation: py:nowhere",
3494 ),
3495 (
3496 "record-unknown-decision",
3497 vec![
3498 process.clone(),
3499 phase.clone(),
3500 json!({"t":"dec","ctx":1,"id":"py:nodecision","v":"22","o":1}),
3501 outcome.clone(),
3502 exit.clone(),
3503 ],
3504 "unknown obligation: py:nodecision",
3505 ),
3506 (
3507 "record-short-vector",
3508 vec![
3509 process.clone(),
3510 phase.clone(),
3511 json!({"t":"dec","ctx":1,"id":decision,"v":"2","o":1}),
3512 outcome.clone(),
3513 exit.clone(),
3514 ],
3515 "reported 1 condition values, expected 2",
3516 ),
3517 ] {
3518 let said = read(name, records);
3519 assert!(said.contains(reason), "{name}: {said}");
3520 }
3521 }
3522
3523 #[test]
3528 fn a_transport_reads_by_its_own_versions_checksum() {
3529 let obligations = build_python_obligations("m.py", "x = 1\n").unwrap();
3530 let process = json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"p","argv":[]});
3531 let read = |name: &str, version: u32, corrupt: Option<usize>| {
3532 let directory = temporary(name);
3533 write_transport_version(
3534 &directory.join("main.1.mmap"),
3535 std::slice::from_ref(&process),
3536 0,
3537 version,
3538 corrupt,
3539 );
3540 let outcome = build_python_frontend_run(
3541 &obligations.manifest,
3542 &directory,
3543 "run-1",
3544 "now",
3545 0,
3546 Path::new(""),
3547 );
3548 fs::remove_dir_all(directory).unwrap();
3549 outcome.map(|_| ()).map_err(|error| error.to_string())
3550 };
3551 let current = read("checksum-v2", TRANSPORT_VERSION, None);
3552 let older = read("checksum-v1", TRANSPORT_VERSION_FNV, None);
3553 assert_eq!(
3554 older, current,
3555 "a version 1 transport reads as a version 2 one does"
3556 );
3557 assert!(
3558 !current
3559 .as_ref()
3560 .err()
3561 .is_some_and(|error| error.contains("checksum")),
3562 "{current:?}"
3563 );
3564 let corrupted = read("checksum-bad", TRANSPORT_VERSION, Some(0)).unwrap_err();
3565 assert!(corrupted.contains("checksum does not match"), "{corrupted}");
3566 let unknown = read("checksum-v3", TRANSPORT_VERSION + 1, None).unwrap_err();
3567 assert!(unknown.contains("version"), "{unknown}");
3568 }
3569
3570 #[test]
3571 fn mixed_pytest_and_unittest_runs_have_distinct_runner_limitations() {
3572 let obligations = build_python_obligations("m.py", "def f():\n return 1\n").unwrap();
3573 let directory = temporary("mixed-runners");
3574 let mut records = vec![json!({"t":"process","v":1,"run":"run-1","pid":1,
3575 "worker":"main","python":"3.11.11","executable":"python","argv":["pytest"]})];
3576 for (index, runner) in [PYTEST_RUNNER, UNITTEST_RUNNER].iter().enumerate() {
3577 let test = format!("tests/test_m.py::test_{runner}");
3578 records.push(
3579 json!({"t":"phase","ctx":index+1,"at":index+5,"worker":"main",
3580 "test":test,"retry":0,"phase":"call"}),
3581 );
3582 records.push(json!({"t":"hit","ctx":index+1,"id":obligations.plan.statements[0].id}));
3583 records.push(json!({"t":"outcome","worker":"main","test":test,"retry":0,
3584 "phase":"call","outcome":"passed","xfail":false,"runner":runner}));
3585 }
3586 write_transport(&directory.join("main.1.mmap"), &records, 0);
3587 let run = build_python_frontend_run(
3588 &obligations.manifest,
3589 &directory,
3590 "run-1",
3591 "now",
3592 0,
3593 Path::new(""),
3594 )
3595 .unwrap();
3596 assert_eq!(run.tests, 2);
3597 assert_eq!(run.declaration.runners.len(), 2);
3598 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
3599 for runner in &run.declaration.runners {
3600 assert_eq!(runner.limitations.len(), 1);
3601 assert_eq!(runner.attribution.action, AttributionPrecision::Unavailable);
3602 assert_eq!(
3603 runner.limitations[0].scopes,
3604 [FrontendLimitationScope::Action]
3605 );
3606 }
3607 fs::remove_dir_all(directory).unwrap();
3608 }
3609
3610 #[test]
3611 fn a_phase_its_runner_reported_more_than_once_is_one_phase() {
3612 let source = "def f(a):\n return a\n";
3620 let obligations = build_python_obligations("m.py", source).unwrap();
3621 let directory = temporary("subtests");
3622 let subtest = |outcome: &str| {
3623 json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_many","retry":0,
3624 "phase":"call","outcome":outcome,"xfail":false})
3625 };
3626 let lines = [
3627 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"python","argv":["pytest"]}),
3628 json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_many","retry":0,"phase":"call"}),
3629 json!({"t":"hit","ctx":1,"id":obligations.plan.statements[0].id}),
3630 json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_many","retry":0,"phase":"setup","outcome":"passed","xfail":false}),
3631 subtest("passed"),
3632 subtest("passed"),
3633 subtest("failed"),
3634 subtest("passed"),
3635 json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_many","retry":0,"phase":"teardown","outcome":"passed","xfail":false}),
3636 json!({"t":"exit","at":9}),
3637 ];
3638 write_transport(&directory.join("main.1.mmap"), &lines, 0);
3639 let run = build_python_frontend_run(
3640 &obligations.manifest,
3641 &directory,
3642 "run-1",
3643 "now",
3644 1,
3645 Path::new(""),
3646 )
3647 .unwrap();
3648 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
3651 let test = &run.request.raw_results[0];
3652 assert_eq!(
3653 test.phases.len(),
3654 3,
3655 "setup, one call, teardown: {:?}",
3656 test.phases
3657 );
3658 assert_eq!(
3659 test.phases
3660 .iter()
3661 .filter(|phase| phase.kind == "test")
3662 .count(),
3663 1,
3664 "the subtests share the call phase their coverage was recorded under"
3665 );
3666 assert_eq!(
3667 test.status.as_deref(),
3668 Some("failed"),
3669 "a subtest that failed fails its test"
3670 );
3671 assert_eq!(test.runtime.len(), 1);
3673 assert!(!test.runtime[0].hits.is_empty());
3674 fs::remove_dir_all(directory).unwrap();
3675 }
3676
3677 #[test]
3678 fn fails_closed_without_an_interpreter_or_tests() {
3679 let obligations = build_python_obligations("m.py", "x = 1\n").unwrap();
3680 let directory = temporary("empty");
3681 assert!(matches!(
3682 build_python_frontend_run(
3683 &obligations.manifest,
3684 &directory,
3685 "run-1",
3686 "now",
3687 0,
3688 Path::new("")
3689 ),
3690 Err(PythonEvidenceError::NoInterpreter)
3691 ));
3692 let path = directory.join("main.1.mmap");
3693 write_transport(
3694 &path,
3695 &[
3696 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"p","argv":[]}),
3697 ],
3698 0,
3699 );
3700 assert!(matches!(
3701 build_python_frontend_run(
3702 &obligations.manifest,
3703 &directory,
3704 "run-1",
3705 "now",
3706 0,
3707 Path::new("")
3708 ),
3709 Err(PythonEvidenceError::NoTests)
3710 ));
3711 let mut torn = fs::read(&path).unwrap();
3715 let process_length = transport_u32(&torn, TRANSPORT_HEADER_SIZE + 4).unwrap() as usize;
3716 let torn_cursor =
3717 align_transport(TRANSPORT_HEADER_SIZE + TRANSPORT_RECORD_HEADER_SIZE + process_length)
3718 .unwrap();
3719 torn[torn_cursor + 4..torn_cursor + 8].copy_from_slice(&5_u32.to_le_bytes());
3720 torn[torn_cursor + TRANSPORT_RECORD_HEADER_SIZE
3721 ..torn_cursor + TRANSPORT_RECORD_HEADER_SIZE + 5]
3722 .copy_from_slice(b"{nope");
3723 fs::write(&path, torn).unwrap();
3724 assert!(matches!(
3725 build_python_frontend_run(
3726 &obligations.manifest,
3727 &directory,
3728 "run-1",
3729 "now",
3730 0,
3731 Path::new("")
3732 ),
3733 Err(PythonEvidenceError::NoTests)
3734 ));
3735 write_transport(
3736 &path,
3737 &[
3738 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.8.20","executable":"p","argv":[]}),
3739 ],
3740 0,
3741 );
3742 assert!(matches!(
3743 build_python_frontend_run(
3744 &obligations.manifest,
3745 &directory,
3746 "run-1",
3747 "now",
3748 0,
3749 Path::new("")
3750 ),
3751 Err(PythonEvidenceError::UnsupportedPython(_))
3752 ));
3753 write_transport(
3754 &path,
3755 &[
3756 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"p","argv":[]}),
3757 ],
3758 2,
3759 );
3760 assert!(matches!(
3761 build_python_frontend_run(
3762 &obligations.manifest,
3763 &directory,
3764 "run-1",
3765 "now",
3766 0,
3767 Path::new("")
3768 ),
3769 Err(PythonEvidenceError::DroppedRecords { count: 2, .. })
3770 ));
3771 fs::remove_dir_all(directory).unwrap();
3772 }
3773}