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 raw_results.push(RawTestResult {
2047 test_id: Some(test.clone()),
2048 scope: Some(scope(run_id, &worker, &test, retry)),
2049 test: test.clone(),
2050 test_file: test_files
2054 .get(&(worker.clone(), test.clone(), retry))
2055 .and_then(|path| relative(root, path))
2056 .or_else(|| test.split("::").next().map(str::to_owned)),
2057 title: test.rsplit("::").next().map(str::to_owned),
2058 retry: Some(retry),
2059 status: Some(status),
2060 expected_status: Some(
2061 if outcomes.iter().any(|(_, _, xfail)| *xfail) {
2062 "failed"
2063 } else {
2064 "passed"
2065 }
2066 .into(),
2067 ),
2068 flaky: false,
2069 provenance: TestProvenance {
2070 runner: runner.clone(),
2071 kind: "unit".into(),
2072 project: None,
2073 source: PYTHON_FRONTEND_VERSION.into(),
2074 },
2075 role: "test".into(),
2076 attribution: crate::coverage_report::ATTRIBUTION_EXACT.into(),
2077 phases,
2078 runtime,
2079 browser: Vec::new(),
2080 server: Vec::new(),
2081 });
2082 }
2083 let default_observed = observed_runners
2086 .iter()
2087 .next()
2088 .cloned()
2089 .unwrap_or_else(default_runner);
2090 for ((worker, test, retry), identities) in identities_by_attempt {
2091 let runner = default_observed.clone();
2092 let mut phases = Vec::new();
2093 let mut runtime = Vec::new();
2094 for (position, (identity, _)) in identities.iter().enumerate() {
2095 let id = phase_id(run_id, identity);
2096 phases.push(CoveragePhase {
2097 id: id.clone(),
2098 kind: match identity.phase.as_str() {
2099 "call" => "test",
2100 value => value,
2101 }
2102 .into(),
2103 operation: format!("{runner} {}", identity.phase),
2104 source: Some(test.clone()),
2105 caused_by_phase_id: None,
2106 started_at_ms: position as i64 * 2 + 1,
2107 ended_at_ms: None,
2108 status: Some("failed".into()),
2109 error: Some("the phase started but the runner reported no outcome".into()),
2110 });
2111 runtime.push(take(identity)?);
2112 }
2113 raw_results.push(RawTestResult {
2114 test_id: Some(test.clone()),
2115 scope: Some(scope(run_id, &worker, &test, retry)),
2116 test: test.clone(),
2117 test_file: test_files
2121 .get(&(worker.clone(), test.clone(), retry))
2122 .and_then(|path| relative(root, path))
2123 .or_else(|| test.split("::").next().map(str::to_owned)),
2124 title: test.rsplit("::").next().map(str::to_owned),
2125 retry: Some(retry),
2126 status: Some("failed".into()),
2127 expected_status: Some("passed".into()),
2128 flaky: false,
2129 provenance: TestProvenance {
2130 runner: runner.clone(),
2131 kind: "unit".into(),
2132 project: None,
2133 source: PYTHON_FRONTEND_VERSION.into(),
2134 },
2135 role: "test".into(),
2136 attribution: crate::coverage_report::ATTRIBUTION_EXACT.into(),
2137 phases,
2138 runtime,
2139 browser: Vec::new(),
2140 server: Vec::new(),
2141 });
2142 }
2143 for (worker, observations) in &background {
2144 if observations.is_empty() {
2145 continue;
2146 }
2147 let test = format!("__supercov_background__:{worker}");
2148 let identity = Identity {
2149 worker: worker.clone(),
2150 test: test.clone(),
2151 retry: 0,
2152 phase: "background".into(),
2153 };
2154 let phase = phase_id(run_id, &identity);
2155 raw_results.push(RawTestResult {
2156 test_id: Some(test.clone()),
2157 scope: Some(scope(run_id, worker, &test, 0)),
2158 test: "Python import, collection and background execution".into(),
2159 test_file: None,
2160 title: None,
2161 retry: Some(0),
2162 status: Some("unknown".into()),
2163 expected_status: None,
2164 flaky: false,
2165 provenance: TestProvenance {
2166 runner: default_observed.clone(),
2167 kind: "unit".into(),
2168 project: None,
2169 source: PYTHON_FRONTEND_VERSION.into(),
2170 },
2171 role: "background".into(),
2172 attribution: crate::coverage_report::ATTRIBUTION_EXACT.into(),
2173 phases: vec![CoveragePhase {
2174 id: phase.clone(),
2175 kind: "background".into(),
2176 operation: "Python import and collection background".into(),
2177 source: None,
2178 caused_by_phase_id: None,
2179 started_at_ms: 0,
2180 ended_at_ms: Some(0),
2181 status: Some("passed".into()),
2182 error: None,
2183 }],
2184 runtime: vec![snapshot(&index, observations, &slot_ids, &phase)?],
2185 browser: Vec::new(),
2186 server: Vec::new(),
2187 });
2188 }
2189
2190 let mut limitation_ids = manifest
2194 .limitations
2195 .iter()
2196 .filter_map(|item| item.get("id").and_then(serde_json::Value::as_str))
2197 .map(str::to_owned)
2198 .collect::<BTreeSet<_>>();
2199 let mut unmeasured = manifest.unmeasured.iter().cloned().collect::<BTreeSet<_>>();
2200 let mut new_limitations = Vec::new();
2201 let observed = raw_results
2204 .iter()
2205 .flat_map(|raw| raw.runtime.iter())
2206 .flat_map(|snapshot| snapshot.hits.iter().cloned())
2207 .collect::<std::collections::HashSet<String>>();
2208 for limitation in limitations.values() {
2209 if let Some(obligation) = &limitation.obligation {
2210 if !index.lines.contains_key(obligation.as_str()) {
2211 return Err(PythonEvidenceError::UnknownObligation(obligation.clone()));
2212 }
2213 unmeasured.insert(obligation.clone());
2214 } else if let Some(file) = &limitation.file {
2215 let observed_elsewhere = limitation.id == "python-probes-unobserved-module"
2229 && index
2230 .files
2231 .get(file.as_str())
2232 .is_some_and(|obligations| obligations.iter().any(|id| observed.contains(*id)));
2233 if !observed_elsewhere && let Some(obligations) = index.files.get(file.as_str()) {
2234 unmeasured.extend(obligations.iter().map(|id| (*id).to_owned()));
2235 }
2236 }
2237 let id = if limitation.file.is_some() || limitation.obligation.is_some() {
2238 stable_id(
2239 &limitation.id,
2240 &[
2241 limitation.file.as_deref().unwrap_or_default(),
2242 limitation.obligation.as_deref().unwrap_or_default(),
2243 ],
2244 )
2245 } else {
2246 limitation.id.clone()
2247 };
2248 if limitation_ids.insert(id.clone()) {
2249 let (file, line) = limitation
2250 .obligation
2251 .as_deref()
2252 .and_then(|id| {
2253 index
2254 .lines
2255 .get(id)
2256 .map(|(file, line)| ((*file).to_owned(), *line))
2257 })
2258 .unwrap_or_else(|| {
2259 (
2260 limitation.file.clone().unwrap_or_else(|| {
2261 manifest
2262 .points
2263 .first()
2264 .map_or(".".into(), |point| point.file.clone())
2265 }),
2266 1,
2267 )
2268 });
2269 new_limitations.push(json!({
2270 "id": id,
2271 "code": limitation.id,
2272 "kind": "semantic-safety",
2273 "file": file,
2274 "line": line,
2275 "column": 0,
2276 "source": "",
2277 "reason": limitation.reason
2278 }));
2279 }
2280 }
2281 manifest.limitations.extend(new_limitations);
2282 manifest.unmeasured = unmeasured.into_iter().collect();
2283 let structural_limitations = limitation_ids.into_iter().collect::<Vec<_>>();
2284
2285 let tests = raw_results
2289 .iter()
2290 .filter(|raw| raw.role == "test")
2291 .map(|raw| raw.test.as_str())
2292 .collect::<BTreeSet<_>>()
2293 .len();
2294 Ok(PythonFrontendRun {
2295 declaration: FrontendRunDeclaration {
2296 protocol_version: LANGUAGE_FRONTEND_PROTOCOL_VERSION,
2297 frontend_id: "python".into(),
2298 frontend_version: PYTHON_FRONTEND_VERSION.into(),
2299 language: "python".into(),
2300 structural_source: StructuralSource::OwnedProbes,
2301 runners: observed_runners
2302 .iter()
2303 .map(|runner| FrontendRunnerDeclaration {
2304 runner: runner.clone(),
2305 execution_model: if runner == UNITTEST_RUNNER {
2306 ExecutionModel::SerialInProcess
2307 } else {
2308 ExecutionModel::ParallelContextPropagated
2309 },
2310 attribution: FrontendAttribution {
2311 run: AttributionPrecision::Exact,
2312 worker: AttributionPrecision::Exact,
2313 test: AttributionPrecision::Exact,
2314 retry: AttributionPrecision::Exact,
2315 phase: AttributionPrecision::Exact,
2316 action: AttributionPrecision::Unavailable,
2317 assertion: if python_runner(runner) {
2320 AttributionPrecision::Exact
2321 } else {
2322 AttributionPrecision::Unavailable
2323 },
2324 },
2325 limitations: if python_runner(runner) {
2326 vec![FrontendLimitation {
2327 id: format!("python-{runner}-action-linkage"),
2328 scopes: vec![FrontendLimitationScope::Action],
2329 reason: format!("{runner} exposes no general action lifecycle"),
2330 }]
2331 } else {
2332 vec![FrontendLimitation {
2333 id: format!("python-server-{runner}-linkage"),
2334 scopes: vec![
2335 FrontendLimitationScope::Action,
2336 FrontendLimitationScope::Assertion,
2337 ],
2338 reason: format!(
2339 "the server sees each {runner} test's requests, not its actions or assertions"
2340 ),
2341 }]
2342 },
2343 })
2344 .collect(),
2345 structural_limitations,
2346 },
2347 request: CoverageReportRequest {
2348 run_id: run_id.into(),
2349 manifest,
2350 raw_results,
2351 generated_at: generated_at.into(),
2352 coverage_model: Some(python_coverage_model()),
2353 integrity: None,
2354 test_exit_code: ExitCodeInput::Present(Some(test_exit_code)),
2355 },
2356 tests,
2357 interpreters,
2358 python_versions: python_versions.into_iter().collect(),
2359 })
2360}
2361
2362#[cfg(test)]
2363mod tests {
2364 use std::time::{SystemTime, UNIX_EPOCH};
2365
2366 use super::*;
2367 use crate::{
2368 frontend_protocol::validate_frontend_report_request,
2369 python_instrumenter::build_python_obligations,
2370 };
2371
2372 #[test]
2376 fn a_request_naming_no_test_is_the_only_running_tests() {
2377 let identity = |worker: &str, test: &str| Identity {
2378 worker: worker.into(),
2379 test: test.into(),
2380 retry: 0,
2381 phase: "call".into(),
2382 };
2383 let observed = |hit: &str| Observations {
2384 hits: BTreeSet::from([hit.to_owned()]),
2385 ..Observations::default()
2386 };
2387 let served = |worker: &str, test: &str, window: (i64, i64)| ServedTest {
2388 worker: worker.into(),
2389 test_id: test.into(),
2390 retry: 0,
2391 name: test.into(),
2392 title: test.into(),
2393 file: "tests/a.spec.js".into(),
2394 status: "passed".into(),
2395 expected_status: "passed".into(),
2396 provenance: TestProvenance::default(),
2397 window: Some(window),
2398 };
2399 let tests = [
2402 served("playwright:w0", "one", (100, 200)),
2403 served("playwright:w1", "two", (100, 200)),
2404 served("playwright:w1", "three", (300, 400)),
2405 ];
2406 let first = identity("supercov-request:a.mmap", "1");
2407 let late = identity("supercov-request:a.mmap", "2");
2408 let mocked = identity("supercov-request:b.mmap", "1");
2409 let alone = identity("supercov-request:b.mmap", "2");
2410 let mut evidence = Evidence {
2411 per_identity: BTreeMap::from([
2412 (first.clone(), observed("first")),
2413 (late.clone(), observed("late")),
2414 (mocked.clone(), observed("mocked")),
2415 (alone.clone(), observed("alone")),
2416 ]),
2417 requests: vec![
2418 (first, 150, 1, "a.mmap".into(), "main".into()),
2419 (late, 250, 2, "a.mmap".into(), "main".into()),
2420 (mocked, 150, 1, "b.mmap".into(), "main".into()),
2421 (alone, 350, 2, "b.mmap".into(), "main".into()),
2422 ],
2423 served_by: BTreeMap::from([(
2424 "a.mmap".to_owned(),
2425 BTreeSet::from([("playwright:w0".to_owned(), "one".to_owned(), 0)]),
2426 )]),
2427 ..Evidence::default()
2428 };
2429 let credited = credit_requests_by_time(&mut evidence, |evidence, transport, at, _| {
2430 crate::served_tests::owner(&tests, evidence.served_by.get(transport), at)
2431 .map(|test| (test.worker.clone(), test.test_id.clone(), test.retry))
2432 });
2433 assert_eq!(
2434 credited,
2435 BTreeSet::from([
2436 ("playwright:w0".to_owned(), "one".to_owned(), 0),
2437 ("playwright:w1".to_owned(), "three".to_owned(), 0),
2438 ])
2439 );
2440 let hits = |worker: &str, test: &str| {
2441 evidence
2442 .per_identity
2443 .get(&identity(worker, test))
2444 .map(|observations| observations.hits.clone())
2445 .unwrap_or_default()
2446 };
2447 assert_eq!(
2449 hits("playwright:w0", "one"),
2450 BTreeSet::from(["first".to_owned()])
2451 );
2452 assert_eq!(
2453 hits("playwright:w1", "three"),
2454 BTreeSet::from(["alone".to_owned()])
2455 );
2456 assert_eq!(
2459 evidence.background["main"].hits,
2460 BTreeSet::from(["late".to_owned(), "mocked".to_owned()])
2461 );
2462 assert!(
2463 evidence
2464 .per_identity
2465 .keys()
2466 .all(|identity| !identity.worker.starts_with(REQUEST_WORKER))
2467 );
2468 }
2469
2470 fn temporary(name: &str) -> std::path::PathBuf {
2471 let nonce = SystemTime::now()
2472 .duration_since(UNIX_EPOCH)
2473 .unwrap()
2474 .as_nanos();
2475 let path = std::env::temp_dir().join(format!(
2476 "supercov-python-evidence-{}-{nonce}-{name}",
2477 std::process::id()
2478 ));
2479 fs::create_dir_all(&path).unwrap();
2480 path
2481 }
2482
2483 fn transport_bytes(payloads: &[&[u8]]) -> Vec<u8> {
2485 let capacity = payloads
2486 .iter()
2487 .fold(TRANSPORT_HEADER_SIZE, |cursor, payload| {
2488 align_transport(cursor + TRANSPORT_RECORD_HEADER_SIZE + payload.len()).unwrap()
2489 });
2490 let mut bytes = vec![0_u8; capacity];
2491 bytes[..8].copy_from_slice(TRANSPORT_MAGIC);
2492 bytes[8..12].copy_from_slice(&TRANSPORT_VERSION.to_le_bytes());
2493 bytes[12..16].copy_from_slice(&(TRANSPORT_HEADER_SIZE as u32).to_le_bytes());
2494 bytes[16..24].copy_from_slice(&(capacity as u64).to_le_bytes());
2495 bytes[32..40].copy_from_slice(&1_u64.to_le_bytes());
2496 let mut cursor = TRANSPORT_HEADER_SIZE;
2497 for payload in payloads {
2498 let start = cursor + TRANSPORT_RECORD_HEADER_SIZE;
2499 bytes[start..start + payload.len()].copy_from_slice(payload);
2500 bytes[cursor + 4..cursor + 8].copy_from_slice(&(payload.len() as u32).to_le_bytes());
2501 bytes[cursor + 8..cursor + 12].copy_from_slice(&crc32fast::hash(payload).to_le_bytes());
2502 bytes[cursor] = 1;
2503 cursor = align_transport(start + payload.len()).unwrap();
2504 }
2505 bytes
2506 }
2507
2508 #[test]
2509 fn a_damaged_transport_is_refused_with_what_is_wrong() {
2510 let exit: &[u8] = br#"{"t":"exit","at":9}"#;
2514 let valid = transport_bytes(&[exit]);
2515 let read = |bytes: &[u8]| match parse_transport("main.1.mmap", bytes, &[], None) {
2516 Ok(_) => "accepted".to_owned(),
2517 Err(error) => error.to_string(),
2518 };
2519 assert_eq!(read(&valid), "accepted");
2520 let set = |at: usize, value: &[u8]| {
2523 let mut bytes = valid.clone();
2524 bytes[at..at + value.len()].copy_from_slice(value);
2525 bytes
2526 };
2527 let mut truncated = valid[..99].to_vec();
2528 truncated[16..24].copy_from_slice(&99_u64.to_le_bytes());
2529 for (bytes, reason) in [
2530 (set(0, b"X"), "header or version does not match"),
2531 (
2532 set(8, &99_u32.to_le_bytes()),
2533 "header or version does not match",
2534 ),
2535 (
2536 set(16, &8_u64.to_le_bytes()),
2537 "declared capacity is outside the mapped file",
2538 ),
2539 (set(24, &3_u64.to_le_bytes()), "3"),
2540 (set(32, &0_u64.to_le_bytes()), "process id is missing"),
2541 (set(64, &[2]), "commit marker or reserved bytes are invalid"),
2542 (
2543 set(68, &0_u32.to_le_bytes()),
2544 "payload length is outside the transport bound",
2545 ),
2546 (
2547 set(68, &64_u32.to_le_bytes()),
2548 "payload extends past the mapped file",
2549 ),
2550 (truncated, "aligned frame extends past the mapped file"),
2551 (set(100, &[1]), "frame padding is not zero"),
2552 (
2553 set(72, &0_u32.to_le_bytes()),
2554 "payload checksum does not match",
2555 ),
2556 (transport_bytes(&[b"{not json"]), "key must be a string"),
2557 (
2558 transport_bytes(&[br#"{"t":"runs","ctx":1,"r":[]}"#]),
2559 "slot runs arrived without the run's slot layout",
2560 ),
2561 ] {
2562 let said = read(&bytes);
2563 assert!(said.contains(reason), "{reason}: {said}");
2564 }
2565 let mut uncommitted = transport_bytes(&[exit, exit]);
2568 uncommitted[104] = 0;
2569 assert_eq!(read(&uncommitted), "accepted");
2570 }
2571
2572 fn write_transport(path: &Path, records: &[serde_json::Value], dropped: u64) {
2573 write_transport_version(path, records, dropped, TRANSPORT_VERSION, None);
2574 }
2575
2576 fn write_transport_version(
2579 path: &Path,
2580 records: &[serde_json::Value],
2581 dropped: u64,
2582 version: u32,
2583 corrupt: Option<usize>,
2584 ) {
2585 let payloads = records
2586 .iter()
2587 .map(|record| serde_json::to_vec(record).unwrap())
2588 .collect::<Vec<_>>();
2589 let capacity = payloads
2590 .iter()
2591 .fold(TRANSPORT_HEADER_SIZE, |cursor, payload| {
2592 align_transport(cursor + TRANSPORT_RECORD_HEADER_SIZE + payload.len()).unwrap()
2593 })
2594 + 64;
2595 let mut bytes = vec![0_u8; capacity];
2596 bytes[..8].copy_from_slice(TRANSPORT_MAGIC);
2597 bytes[8..12].copy_from_slice(&version.to_le_bytes());
2598 bytes[12..16].copy_from_slice(&(TRANSPORT_HEADER_SIZE as u32).to_le_bytes());
2599 bytes[16..24].copy_from_slice(&(capacity as u64).to_le_bytes());
2600 bytes[24..32].copy_from_slice(&dropped.to_le_bytes());
2601 bytes[32..40].copy_from_slice(&1_u64.to_le_bytes());
2602 let mut cursor = TRANSPORT_HEADER_SIZE;
2603 for (index, payload) in payloads.into_iter().enumerate() {
2604 let payload_start = cursor + TRANSPORT_RECORD_HEADER_SIZE;
2605 let payload_end = payload_start + payload.len();
2606 bytes[payload_start..payload_end].copy_from_slice(&payload);
2607 bytes[cursor + 4..cursor + 8].copy_from_slice(&(payload.len() as u32).to_le_bytes());
2608 let expected = if version == TRANSPORT_VERSION_FNV {
2612 payload.iter().fold(0x811c_9dc5_u32, |value, byte| {
2613 (value ^ u32::from(*byte)).wrapping_mul(0x0100_0193)
2614 })
2615 } else {
2616 crc32fast::hash(&payload)
2617 };
2618 let checksum = expected ^ u32::from(corrupt == Some(index));
2619 bytes[cursor + 8..cursor + 12].copy_from_slice(&checksum.to_le_bytes());
2620 bytes[cursor] = 1;
2621 cursor = align_transport(payload_end).unwrap();
2622 }
2623 fs::write(path, bytes).unwrap();
2624 }
2625
2626 fn under(first: &str, rest: &str) -> String {
2631 let mut path = PathBuf::from(if cfg!(windows) {
2632 format!("C:\\{first}")
2633 } else {
2634 format!("/{first}")
2635 });
2636 for part in rest.split('/').filter(|part| !part.is_empty()) {
2637 path.push(part);
2638 }
2639 path.to_string_lossy().into_owned()
2640 }
2641
2642 fn assertion_sources(run: &PythonFrontendRun) -> Vec<String> {
2643 run.request.raw_results[0]
2644 .phases
2645 .iter()
2646 .filter(|phase| phase.kind == "assertion")
2647 .filter_map(|phase| phase.source.clone())
2648 .collect()
2649 }
2650
2651 fn run_with_sites(
2652 name: &str,
2653 sites: &[serde_json::Value],
2654 outcome_file: Option<&str>,
2655 root: &str,
2656 ) -> PythonFrontendRun {
2657 let source = "def f(a):\n return a\n";
2658 let obligations = build_python_obligations("m.py", source).unwrap();
2659 let mut outcome = json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call","outcome":"passed","xfail":false});
2660 if let Some(file) = outcome_file {
2661 outcome["file"] = json!(file);
2662 }
2663 let mut lines = vec![
2664 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"python","argv":["pytest"]}),
2665 json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call"}),
2666 json!({"t":"assert","ctx":1}),
2667 ];
2668 lines.extend(sites.iter().cloned());
2669 lines.push(outcome);
2670 lines.push(json!({"t":"exit","at":9}));
2671 let directory = temporary(name);
2672 write_transport(&directory.join("main.1.mmap"), &lines, 0);
2673 let run = build_python_frontend_run(
2674 &obligations.manifest,
2675 &directory,
2676 "run-1",
2677 "now",
2678 0,
2679 Path::new(root),
2680 )
2681 .unwrap();
2682 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
2683 fs::remove_dir_all(directory).unwrap();
2684 run
2685 }
2686
2687 const SLOT_DIGEST: &str = "0123456789abcdef0123456789abcdef";
2688
2689 fn slot_layout() -> serde_json::Value {
2693 json!({
2694 "version": 2, "digest": SLOT_DIGEST, "header": 16, "bytes": 36,
2695 "ids": ["s1", "s2"],
2696 "decisions": [{
2697 "start": 18, "width": 2, "id": "d",
2698 "outcomeTrue": "d:true", "outcomeFalse": "d:false",
2699 "logical": [{"evaluated": "or:evaluated", "shortCircuit": "or:short",
2700 "previousLeaves": [0], "operandLeaves": [1]}]
2701 }]
2702 })
2703 }
2704
2705 fn slot_bytes(context: u64, tag: Option<&str>, set: &[usize]) -> Vec<u8> {
2707 let mut bytes = vec![0_u8; 36];
2708 bytes[..8].copy_from_slice(&context.to_le_bytes());
2709 if let Some(tag) = tag {
2710 for index in 0..8 {
2711 bytes[8 + index] = u8::from_str_radix(&tag[index * 2..index * 2 + 2], 16).unwrap();
2712 }
2713 }
2714 for index in set {
2715 bytes[*index] = 1;
2716 }
2717 bytes
2718 }
2719
2720 fn killed_process(name: &str, slot: &[u8]) -> PathBuf {
2723 let directory = temporary(name);
2724 write_transport(
2725 &directory.join("main.1.token.mmap"),
2726 &[
2727 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.9.21","executable":"python","argv":["pytest"]}),
2728 json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call"}),
2729 ],
2730 0,
2731 );
2732 fs::write(directory.join("main.1.token.0.slot"), slot).unwrap();
2733 fs::write(
2734 directory.join("layout.json"),
2735 serde_json::to_vec(&slot_layout()).unwrap(),
2736 )
2737 .unwrap();
2738 directory
2739 }
2740
2741 fn call_observations(evidence: &Evidence) -> Observations {
2742 evidence
2743 .per_identity
2744 .iter()
2745 .find(|(identity, _)| identity.phase == "call")
2746 .map(|(_, observations)| Observations {
2747 hits: observations
2749 .hits
2750 .iter()
2751 .cloned()
2752 .chain(
2753 observations
2754 .slot_hits
2755 .iter()
2756 .map(|position| evidence.slot_ids[*position as usize].clone()),
2757 )
2758 .collect(),
2759 slot_hits: Vec::new(),
2760 vectors: observations.vectors.clone(),
2761 })
2762 .unwrap_or_default()
2763 }
2764
2765 #[test]
2766 fn a_killed_process_slot_is_read_as_its_last_records() {
2767 let directory = killed_process(
2770 "py-slot-killed",
2771 &slot_bytes(1, Some(SLOT_DIGEST), &[16, 18 + 15]),
2772 );
2773 let evidence = read_evidence_directory(&directory, "run-1").unwrap();
2774 let call = call_observations(&evidence);
2775 assert_eq!(
2776 call.hits.iter().cloned().collect::<Vec<_>>(),
2777 ["d:true", "or:evaluated", "s1"],
2778 "the obligation, and what the vector implies"
2779 );
2780 assert_eq!(
2781 call.vectors["d"],
2782 BTreeSet::from([(vec![Some(false), Some(true)], true)])
2783 );
2784 fs::remove_dir_all(directory).unwrap();
2785 }
2786
2787 #[test]
2788 fn a_head_byte_is_read_with_the_bytes_it_stands_for() {
2789 let directory = temporary("py-slot-implied");
2793 write_transport(
2794 &directory.join("main.1.token.mmap"),
2795 &[
2796 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.9.21","executable":"python","argv":["pytest"]}),
2797 json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call"}),
2798 json!({"t":"runs","ctx":1,"r":[17, 1]}),
2799 ],
2800 0,
2801 );
2802 let mut layout = slot_layout();
2803 layout["implied"] = json!([[17, [16, 18 + 15]]]);
2804 fs::write(
2805 directory.join("layout.json"),
2806 serde_json::to_vec(&layout).unwrap(),
2807 )
2808 .unwrap();
2809 let evidence = read_evidence_directory(&directory, "run-1").unwrap();
2810 let call = call_observations(&evidence);
2811 assert_eq!(
2812 call.hits.iter().cloned().collect::<Vec<_>>(),
2813 ["d:true", "or:evaluated", "s1", "s2"]
2814 );
2815 assert_eq!(
2816 call.vectors["d"],
2817 BTreeSet::from([(vec![Some(false), Some(true)], true)])
2818 );
2819 for implied in [
2822 json!([[17, [17]]]),
2823 json!([[17, [36]]]),
2824 json!([[17, [16]], [16, [18]]]),
2825 ] {
2826 layout["implied"] = implied;
2827 fs::write(
2828 directory.join("layout.json"),
2829 serde_json::to_vec(&layout).unwrap(),
2830 )
2831 .unwrap();
2832 assert!(read_evidence_directory(&directory, "run-1").is_err());
2833 }
2834 fs::remove_dir_all(directory).unwrap();
2835 }
2836
2837 #[test]
2838 fn a_slot_gone_since_the_listing_is_skipped_and_nothing_else_is() {
2839 let directory = temporary("py-slot-vanished");
2844 let gone = directory.join("main.1.token.0.slot");
2845 assert!(
2846 entry_metadata("main.1.token.0.slot", &gone)
2847 .unwrap()
2848 .is_none()
2849 );
2850 assert!(map_slot(&gone).unwrap().is_none());
2851 assert!(entry_metadata("main.1.token.mmap", &directory.join("main.1.token.mmap")).is_err());
2852 assert!(entry_metadata("layout.json", &directory.join("layout.json")).is_err());
2853 fs::write(&gone, slot_bytes(1, Some(SLOT_DIGEST), &[16])).unwrap();
2854 assert!(
2855 entry_metadata("main.1.token.0.slot", &gone)
2856 .unwrap()
2857 .is_some()
2858 );
2859 assert!(map_slot(&gone).unwrap().is_some());
2860 fs::remove_dir_all(directory).unwrap();
2861 }
2862
2863 #[test]
2864 fn a_slot_its_process_closed_is_not_counted() {
2865 let directory = killed_process("py-slot-closed", &slot_bytes(1, None, &[16]));
2868 let evidence = read_evidence_directory(&directory, "run-1").unwrap();
2869 assert!(call_observations(&evidence).hits.is_empty());
2870 fs::remove_dir_all(directory).unwrap();
2871 }
2872
2873 #[test]
2874 fn a_slot_is_read_only_against_its_own_layout() {
2875 let other = "fedcba9876543210fedcba9876543210";
2876 let directory = killed_process("py-slot-foreign", &slot_bytes(1, Some(other), &[16]));
2877 assert!(matches!(
2878 read_evidence_directory(&directory, "run-1"),
2879 Err(PythonEvidenceError::InvalidTransport { .. })
2880 ));
2881 fs::remove_dir_all(directory).unwrap();
2882
2883 let directory = killed_process("py-slot-byte", &slot_bytes(1, Some(SLOT_DIGEST), &[]));
2885 let mut bytes = slot_bytes(1, Some(SLOT_DIGEST), &[]);
2886 bytes[17] = 7;
2887 fs::write(directory.join("main.1.token.0.slot"), bytes).unwrap();
2888 assert!(read_evidence_directory(&directory, "run-1").is_err());
2889 fs::remove_dir_all(directory).unwrap();
2890 }
2891
2892 #[test]
2893 fn a_slot_needs_its_transport_and_a_partial_layout_is_ignored() {
2894 let directory = killed_process("py-slot-orphan", &slot_bytes(1, Some(SLOT_DIGEST), &[16]));
2895 fs::write(directory.join("layout.json.42.token.partial"), b"{").unwrap();
2897 assert!(read_evidence_directory(&directory, "run-1").is_ok());
2898 fs::write(
2899 directory.join("other.9.token.0.slot"),
2900 slot_bytes(1, Some(SLOT_DIGEST), &[16]),
2901 )
2902 .unwrap();
2903 assert!(matches!(
2904 read_evidence_directory(&directory, "run-1"),
2905 Err(PythonEvidenceError::InvalidTransport { .. })
2906 ));
2907 fs::remove_dir_all(directory).unwrap();
2908 }
2909
2910 #[test]
2911 fn a_test_s_assertions_are_its_one_assertion_phase() {
2912 let run = run_with_sites("py-assertion-phase", &[], None, &under("project", ""));
2915 assert_eq!(
2916 assertion_sources(&run),
2917 vec!["tests/test_m.py::test_a".to_string()]
2918 );
2919 }
2920
2921 #[test]
2922 fn the_runner_names_the_test_file_in_either_path_form() {
2923 let root = under("project", "");
2928 for reported in [
2929 under("project", "tests/test_m.py"),
2930 "tests/test_m.py".to_owned(),
2931 "./tests/test_m.py".to_owned(),
2932 ] {
2933 let run = run_with_sites("py-asite-file", &[], Some(reported.as_str()), &root);
2934 assert_eq!(
2935 run.request.raw_results[0].test_file.as_deref(),
2936 Some("tests/test_m.py"),
2937 "{reported} should resolve to the project path"
2938 );
2939 }
2940 let without = run_with_sites("py-asite-nofile", &[], None, &root);
2941 assert_eq!(
2942 without.request.raw_results[0].test_file.as_deref(),
2943 Some("tests/test_m.py"),
2944 "a pytest node id already begins with the file"
2945 );
2946 }
2947
2948 fn phased_run(name: &str) -> PythonFrontendRun {
2952 let source = "def f(a, b):\n if a and b:\n return 1\n return 2\n";
2953 let obligations = build_python_obligations("m.py", source).unwrap();
2954 let statements = &obligations.plan.statements;
2955 let decision = &obligations.manifest.decisions[0].id;
2956 let test = "tests/test_m.py::test_a";
2957 let lines = [
2958 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"python","argv":["pytest"]}),
2959 json!({"t":"hit","ctx":0,"id":statements[0].id}),
2960 json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":test,"retry":0,"phase":"setup"}),
2961 json!({"t":"hits","ctx":1,"ids":[statements[0].id, statements[1].id]}),
2962 json!({"t":"outcome","worker":"main","test":test,"retry":0,"phase":"setup","outcome":"passed","xfail":false}),
2963 json!({"t":"phase","ctx":2,"at":6,"worker":"main","test":test,"retry":0,"phase":"call"}),
2964 json!({"t":"hit","ctx":2,"id":statements[1].id}),
2965 json!({"t":"dec","ctx":2,"id":decision,"v":"22","o":1}),
2966 json!({"t":"assert","ctx":2}),
2967 json!({"t":"hit","ctx":2,"id":statements[2].id}),
2968 json!({"t":"dec","ctx":2,"id":decision,"v":"21","o":0}),
2969 json!({"t":"outcome","worker":"main","test":test,"retry":0,"phase":"call","outcome":"passed","xfail":false}),
2970 json!({"t":"phase","ctx":3,"at":7,"worker":"main","test":test,"retry":0,"phase":"teardown"}),
2971 json!({"t":"hit","ctx":3,"id":statements[2].id}),
2972 json!({"t":"outcome","worker":"main","test":test,"retry":0,"phase":"teardown","outcome":"passed","xfail":false}),
2973 json!({"t":"exit","at":9}),
2974 ];
2975 let directory = temporary(name);
2976 write_transport(&directory.join("main.1.mmap"), &lines, 0);
2977 let run = build_python_frontend_run(
2978 &obligations.manifest,
2979 &directory,
2980 "run-1",
2981 "now",
2982 0,
2983 Path::new(""),
2984 )
2985 .unwrap();
2986 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
2987 fs::remove_dir_all(directory).unwrap();
2988 run
2989 }
2990
2991 #[test]
2992 fn a_snapshot_that_names_its_phase_analyses_as_the_events_it_replaces() {
2993 use crate::coverage_report::{RuntimeEvent, analyze_coverage_results};
2997 let run = phased_run("py-phase-named");
2998 let named = &run.request;
2999 assert!(
3000 named
3001 .raw_results
3002 .iter()
3003 .flat_map(|raw| &raw.runtime)
3004 .filter(|snapshot| snapshot.phase_id.is_some())
3005 .count()
3006 >= 4,
3007 "setup, call, assertion and teardown each name their phase"
3008 );
3009 let mut events = named.clone();
3010 for snapshot in events
3011 .raw_results
3012 .iter_mut()
3013 .flat_map(|raw| raw.runtime.iter_mut())
3014 {
3015 let Some(phase) = snapshot.phase_id.take() else {
3016 continue;
3017 };
3018 let mut clock = 1;
3019 let mut event = |event_type: &str, id: &str, vector: Option<McdcVector>| {
3020 clock += 1;
3021 RuntimeEvent {
3022 event_type: event_type.into(),
3023 id: id.into(),
3024 vector,
3025 timestamp_ms: clock,
3026 phase_id: Some(phase.clone()),
3027 statement_id: None,
3028 environment: "python".into(),
3029 }
3030 };
3031 let mut written = snapshot
3032 .hits
3033 .iter()
3034 .map(|hit| event("hit", hit, None))
3035 .collect::<Vec<_>>();
3036 for decision in &snapshot.decisions {
3037 for vector in &decision.vectors {
3038 written.push(event("decision", &decision.meta.id, Some(vector.clone())));
3039 }
3040 }
3041 snapshot.events = written;
3042 }
3043 let from_names = analyze_coverage_results(named).unwrap();
3044 let from_events = analyze_coverage_results(&events).unwrap();
3045 assert!(
3046 from_names
3047 .view
3048 .phases
3049 .iter()
3050 .any(|phase| phase.explicit_events > 1),
3051 "the fixture's phases carry explicit evidence"
3052 );
3053 assert_eq!(
3054 serde_json::to_value(&from_names).unwrap(),
3055 serde_json::to_value(&from_events).unwrap()
3056 );
3057 }
3058
3059 #[test]
3060 fn analysing_the_archived_request_is_analysing_the_archive() {
3061 use crate::coverage_report::{
3064 ArchiveReportRequest, ExitCodeInput, TransportStats, analyze_coverage_archive,
3065 analyze_frontend_request,
3066 };
3067 let run = phased_run("py-archived");
3068 let directory = temporary("py-archived-archive");
3069 let archive = directory.join("evidence.raw.gz");
3070 crate::evidence_archive::write_archive(run.archive_entries().unwrap(), &archive).unwrap();
3071 let integrity = json!({"schemaVersion": 2});
3072 let from_archive = analyze_coverage_archive(&ArchiveReportRequest {
3073 archive_path: archive,
3074 run_id: "run-1".into(),
3075 generated_at: "then".into(),
3076 integrity: Some(integrity.clone()),
3077 test_exit_code: ExitCodeInput::Present(Some(0)),
3078 })
3079 .unwrap();
3080 let in_memory = analyze_frontend_request(
3081 &run.declaration,
3082 &CoverageReportRequest {
3083 run_id: "run-1".into(),
3084 generated_at: "then".into(),
3085 integrity: Some(integrity),
3086 test_exit_code: ExitCodeInput::Present(Some(0)),
3087 ..run.request.clone()
3088 },
3089 TransportStats::none(),
3090 )
3091 .unwrap();
3092 fs::remove_dir_all(directory).unwrap();
3093 assert_eq!(
3094 serde_json::to_value(&in_memory).unwrap(),
3095 serde_json::to_value(&from_archive).unwrap()
3096 );
3097 }
3098
3099 #[test]
3100 fn evidence_before_the_first_assertion_links_to_it_when_the_test_passes() {
3101 let source = "def f(a):\n return a\n\n\ndef g(b):\n return b\n";
3106 let obligations = build_python_obligations("m.py", source).unwrap();
3107 let before = &obligations.plan.statements[0].id;
3108 let after = &obligations.plan.statements[1].id;
3109 let run_with = |name: &str, outcome: &str| {
3110 let lines = [
3111 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"python","argv":["pytest"]}),
3112 json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call"}),
3113 json!({"t":"hit","ctx":1,"id":before}),
3114 json!({"t":"assert","ctx":1}),
3115 json!({"t":"assert","ctx":1}),
3116 json!({"t":"hit","ctx":1,"id":after}),
3117 json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call","outcome":outcome,"xfail":false}),
3118 json!({"t":"exit","at":9}),
3119 ];
3120 let directory = temporary(name);
3121 write_transport(&directory.join("main.1.mmap"), &lines, 0);
3122 let run = build_python_frontend_run(
3123 &obligations.manifest,
3124 &directory,
3125 "run-1",
3126 "now",
3127 0,
3128 Path::new(""),
3129 )
3130 .unwrap();
3131 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
3132 fs::remove_dir_all(directory).unwrap();
3133 run
3134 };
3135 let events_of = |result: &RawTestResult, phase: &str| -> BTreeSet<String> {
3136 result
3137 .runtime
3138 .iter()
3139 .filter(|snapshot| snapshot.phase_id.as_deref() == Some(phase))
3140 .flat_map(|snapshot| snapshot.hits.iter().cloned())
3141 .collect()
3142 };
3143
3144 let run = run_with("asserted-passed", "passed");
3145 let passed = &run.request.raw_results[0];
3146 assert_eq!(passed.test, "tests/test_m.py::test_a");
3147 let assertion = passed
3148 .phases
3149 .iter()
3150 .find(|phase| phase.kind == "assertion")
3151 .expect("the asserting test carries an assertion phase");
3152 assert_eq!(assertion.status.as_deref(), Some("passed"));
3153 assert_eq!(
3154 events_of(passed, &assertion.id),
3155 BTreeSet::from([before.clone()]),
3156 "only what ran before the marker is the assertion's evidence"
3157 );
3158 let test_phase = passed
3159 .phases
3160 .iter()
3161 .find(|phase| phase.kind == "test")
3162 .unwrap();
3163 assert_eq!(
3164 events_of(passed, &test_phase.id),
3165 BTreeSet::from([before.clone(), after.clone()]),
3166 "the test phase keeps everything it ran"
3167 );
3168 assert_eq!(
3169 run.declaration.runners[0].attribution.assertion,
3170 AttributionPrecision::Exact
3171 );
3172
3173 let run = run_with("asserted-failed", "failed");
3174 let failed = &run.request.raw_results[0];
3175 let assertion = failed
3176 .phases
3177 .iter()
3178 .find(|phase| phase.kind == "assertion")
3179 .unwrap();
3180 assert_eq!(
3181 assertion.status.as_deref(),
3182 Some("failed"),
3183 "a failed test's assertion witnessed nothing"
3184 );
3185 }
3186
3187 #[test]
3188 fn repeated_runtime_limitations_keep_each_affected_file_once() {
3189 let mut manifest = build_python_obligations("a.py", "x = 1\n")
3190 .unwrap()
3191 .manifest;
3192 let second = build_python_obligations("b.py", "y = 2\n")
3193 .unwrap()
3194 .manifest;
3195 manifest.points.extend(second.points);
3196 let directory = temporary("repeated-limitations");
3197 let mut records = vec![
3198 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.11","executable":"python","argv":[]}),
3199 json!({"t":"outcome","worker":"main","test":"test_a","retry":0,"phase":"call","outcome":"passed","xfail":false}),
3200 ];
3201 for _ in 0..1000 {
3202 for file in ["a.py", "b.py"] {
3203 records.push(json!({"t":"limitation","id":"python-probes-unobserved-module","file":file,"reason":"entry script"}));
3204 }
3205 }
3206 write_transport(&directory.join("main.1.mmap"), &records, 0);
3207 let evidence = read_evidence_directory(&directory, "run-1").unwrap();
3208 assert_eq!(
3209 evidence.limitations.len(),
3210 2,
3211 "deduplicate before manifest expansion"
3212 );
3213 let run =
3214 build_python_frontend_run(&manifest, &directory, "run-1", "now", 0, Path::new(""))
3215 .unwrap();
3216 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
3217 let limitations = &run.request.manifest.limitations;
3218 assert_eq!(limitations.len(), 2);
3219 assert_ne!(limitations[0]["id"], limitations[1]["id"]);
3220 assert_eq!(limitations[0]["file"], "a.py");
3221 assert_eq!(limitations[1]["file"], "b.py");
3222 assert_eq!(limitations[0]["code"], "python-probes-unobserved-module");
3223 assert_eq!(run.request.manifest.unmeasured.len(), 2);
3224 fs::remove_dir_all(directory).unwrap();
3225 }
3226
3227 #[test]
3228 fn a_script_run_unobserved_stays_measured_where_its_imports_were_observed() {
3229 let imported = build_python_obligations("imported.py", "def f():\n return 1\n").unwrap();
3233 let only_run = build_python_obligations("only_run.py", "y = 2\n").unwrap();
3234 let mut manifest = imported.manifest.clone();
3235 manifest.points.extend(only_run.manifest.points.clone());
3236 let statement = &imported.plan.statements[0];
3237 let directory = temporary("script-and-import");
3238 let records = vec![
3239 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.11","executable":"python","argv":["pytest"]}),
3240 json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call"}),
3241 json!({"t":"hit","ctx":1,"id":statement.id}),
3242 json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call","outcome":"passed","xfail":false}),
3243 json!({"t":"limitation","id":"python-probes-unobserved-module","file":"imported.py","reason":"entry script"}),
3244 json!({"t":"limitation","id":"python-probes-unobserved-module","file":"only_run.py","reason":"entry script"}),
3245 ];
3246 write_transport(&directory.join("main.1.mmap"), &records, 0);
3247 let run =
3248 build_python_frontend_run(&manifest, &directory, "run-1", "now", 0, Path::new(""))
3249 .unwrap();
3250 let unmeasured = &run.request.manifest.unmeasured;
3251 let file_points = |file: &str| {
3252 manifest
3253 .points
3254 .iter()
3255 .filter(|point| point.file == file)
3256 .map(|point| point.id.clone())
3257 .collect::<Vec<_>>()
3258 };
3259 assert!(
3260 file_points("imported.py")
3261 .iter()
3262 .all(|id| !unmeasured.contains(id)),
3263 "an observed file keeps its measurement"
3264 );
3265 assert!(
3266 file_points("only_run.py")
3267 .iter()
3268 .all(|id| unmeasured.contains(id)),
3269 "a file nothing observed stays unmeasured"
3270 );
3271 let files = run
3273 .request
3274 .manifest
3275 .limitations
3276 .iter()
3277 .map(|limitation| limitation["file"].as_str().unwrap().to_owned())
3278 .collect::<BTreeSet<_>>();
3279 assert!(files.contains("imported.py") && files.contains("only_run.py"));
3280 fs::remove_dir_all(directory).unwrap();
3281 }
3282
3283 #[test]
3284 fn joins_phases_outcomes_hits_and_vectors_into_exact_results() {
3285 let source = "def f(a, b):\n if a and b:\n return 1\n return 0\n";
3286 let obligations = build_python_obligations("m.py", source).unwrap();
3287 let decision = &obligations.plan.decisions[0];
3288 let statement = &obligations.plan.statements[0];
3289 let directory = temporary("join");
3290 let lines = [
3291 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"python","argv":["pytest"]}),
3292 json!({"t":"hit","ctx":0,"id":statement.id}),
3293 json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call"}),
3294 json!({"t":"dec","ctx":1,"id":decision.id,"v":"22","o":1}),
3295 json!({"t":"hit","ctx":1,"id":decision.outcome_true}),
3296 json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"setup","outcome":"passed","xfail":false}),
3297 json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call","outcome":"passed","xfail":false}),
3298 json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"teardown","outcome":"passed","xfail":false}),
3299 json!({"t":"limitation","id":"python-decision-partially-mapped","reason":"folded","obligation":decision.id}),
3300 json!({"t":"exit","at":9}),
3301 ];
3302 write_transport(&directory.join("main.1.mmap"), &lines, 0);
3303 let run = build_python_frontend_run(
3304 &obligations.manifest,
3305 &directory,
3306 "run-1",
3307 "now",
3308 0,
3309 Path::new(""),
3310 )
3311 .unwrap();
3312 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
3313 assert_eq!(run.tests, 1);
3314 assert_eq!(run.request.raw_results.len(), 2);
3315 let test = &run.request.raw_results[0];
3316 assert_eq!(test.status.as_deref(), Some("passed"));
3317 assert_eq!(test.phases.len(), 3);
3318 assert_eq!(test.runtime.len(), 1);
3319 assert_eq!(test.runtime[0].decisions.len(), 1);
3320 assert!(test.runtime[0].phase_id.is_some());
3321 assert!(test.runtime[0].events.is_empty());
3322 let background = &run.request.raw_results[1];
3323 assert_eq!(background.role, "background");
3324 assert!(run.request.manifest.unmeasured.contains(&decision.id));
3325 assert!(run.declaration.structural_limitations.contains(&stable_id(
3326 "python-decision-partially-mapped",
3327 &["", &decision.id]
3328 )));
3329 fs::remove_dir_all(directory).unwrap();
3330 }
3331
3332 #[test]
3333 fn a_damaged_record_is_refused_with_what_is_wrong() {
3334 let source = "def f(a, b):\n if a and b:\n return 1\n return 0\n";
3338 let obligations = build_python_obligations("m.py", source).unwrap();
3339 let decision = obligations.plan.decisions[0].id.clone();
3340 let process = json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"python","argv":["pytest"]});
3341 let phase = json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"t::a","retry":0,"phase":"call"});
3342 let outcome = json!({"t":"outcome","worker":"main","test":"t::a","retry":0,"phase":"call","outcome":"passed","xfail":false});
3343 let with = |record: &serde_json::Value, key: &str, value: serde_json::Value| {
3344 let mut changed = record.clone();
3345 changed[key] = value;
3346 changed
3347 };
3348 let read = |name: &str, records: Vec<serde_json::Value>| {
3349 let directory = temporary(name);
3350 write_transport(&directory.join("main.1.mmap"), &records, 0);
3351 let said = match build_python_frontend_run(
3352 &obligations.manifest,
3353 &directory,
3354 "run-1",
3355 "now",
3356 0,
3357 Path::new(""),
3358 ) {
3359 Ok(_) => "accepted".to_owned(),
3360 Err(error) => error.to_string(),
3361 };
3362 fs::remove_dir_all(directory).unwrap();
3363 said
3364 };
3365 let exit = json!({"t":"exit","at":9});
3366 assert_eq!(
3367 read(
3368 "record-valid",
3369 vec![
3370 process.clone(),
3371 phase.clone(),
3372 outcome.clone(),
3373 exit.clone()
3374 ]
3375 ),
3376 "accepted"
3377 );
3378 for (name, records, reason) in [
3379 (
3380 "record-version",
3381 vec![with(&process, "v", json!(2))],
3382 "unsupported Python evidence version 2",
3383 ),
3384 (
3385 "record-run",
3386 vec![with(&process, "run", json!("run-0"))],
3387 "belongs to run run-0, expected run-1",
3388 ),
3389 (
3390 "record-pid",
3391 vec![with(&process, "pid", json!(7))],
3392 "does not match the transport owner",
3393 ),
3394 (
3395 "record-python",
3396 vec![with(&process, "python", json!("3.8.1"))],
3397 "the test command ran Python 3.8.1",
3398 ),
3399 (
3400 "record-context",
3401 vec![process.clone(), with(&phase, "ctx", json!(0))],
3402 "phase context 0 is reserved",
3403 ),
3404 (
3405 "record-phase",
3406 vec![process.clone(), with(&phase, "phase", json!("body"))],
3407 "unknown pytest phase",
3408 ),
3409 (
3410 "record-identity",
3411 vec![process.clone(), with(&phase, "test", json!(" "))],
3412 "must name a worker and test",
3413 ),
3414 (
3415 "record-outcome-phase",
3416 vec![process.clone(), with(&outcome, "phase", json!("body"))],
3417 "unknown test outcome phase",
3418 ),
3419 (
3420 "record-outcome",
3421 vec![process.clone(), with(&outcome, "outcome", json!("maybe"))],
3422 "unknown test outcome",
3423 ),
3424 (
3425 "record-runner",
3426 vec![process.clone(), with(&outcome, "runner", json!("nose"))],
3427 "unknown Python test runner",
3428 ),
3429 (
3430 "record-two-runners",
3431 vec![
3432 process.clone(),
3433 outcome.clone(),
3434 with(&outcome, "runner", json!("unittest")),
3435 ],
3436 "one attempt was reported by two runners",
3437 ),
3438 (
3439 "record-digits",
3440 vec![
3441 process.clone(),
3442 phase.clone(),
3443 json!({"t":"dec","ctx":1,"id":decision,"v":"23","o":1}),
3444 ],
3445 "decision vector digits must be 0, 1 or 2",
3446 ),
3447 (
3448 "record-decision-outcome",
3449 vec![
3450 process.clone(),
3451 phase.clone(),
3452 json!({"t":"dec","ctx":1,"id":decision,"v":"22","o":2}),
3453 ],
3454 "decision outcome must be 0 or 1",
3455 ),
3456 (
3457 "record-batch-digits",
3458 vec![
3459 process.clone(),
3460 phase.clone(),
3461 json!({"t":"decs","ctx":1,"v":[[decision, "", 1]]}),
3462 ],
3463 "decision vector digits must be 0, 1 or 2",
3464 ),
3465 (
3466 "record-batch-outcome",
3467 vec![
3468 process.clone(),
3469 phase.clone(),
3470 json!({"t":"decs","ctx":1,"v":[[decision, "21", 3]]}),
3471 ],
3472 "decision outcome must be 0 or 1",
3473 ),
3474 (
3475 "record-unknown-hit",
3476 vec![
3477 process.clone(),
3478 phase.clone(),
3479 json!({"t":"hit","ctx":1,"id":"py:nowhere"}),
3480 outcome.clone(),
3481 exit.clone(),
3482 ],
3483 "unknown obligation: py:nowhere",
3484 ),
3485 (
3486 "record-unknown-decision",
3487 vec![
3488 process.clone(),
3489 phase.clone(),
3490 json!({"t":"dec","ctx":1,"id":"py:nodecision","v":"22","o":1}),
3491 outcome.clone(),
3492 exit.clone(),
3493 ],
3494 "unknown obligation: py:nodecision",
3495 ),
3496 (
3497 "record-short-vector",
3498 vec![
3499 process.clone(),
3500 phase.clone(),
3501 json!({"t":"dec","ctx":1,"id":decision,"v":"2","o":1}),
3502 outcome.clone(),
3503 exit.clone(),
3504 ],
3505 "reported 1 condition values, expected 2",
3506 ),
3507 ] {
3508 let said = read(name, records);
3509 assert!(said.contains(reason), "{name}: {said}");
3510 }
3511 }
3512
3513 #[test]
3518 fn a_transport_reads_by_its_own_versions_checksum() {
3519 let obligations = build_python_obligations("m.py", "x = 1\n").unwrap();
3520 let process = json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"p","argv":[]});
3521 let read = |name: &str, version: u32, corrupt: Option<usize>| {
3522 let directory = temporary(name);
3523 write_transport_version(
3524 &directory.join("main.1.mmap"),
3525 std::slice::from_ref(&process),
3526 0,
3527 version,
3528 corrupt,
3529 );
3530 let outcome = build_python_frontend_run(
3531 &obligations.manifest,
3532 &directory,
3533 "run-1",
3534 "now",
3535 0,
3536 Path::new(""),
3537 );
3538 fs::remove_dir_all(directory).unwrap();
3539 outcome.map(|_| ()).map_err(|error| error.to_string())
3540 };
3541 let current = read("checksum-v2", TRANSPORT_VERSION, None);
3542 let older = read("checksum-v1", TRANSPORT_VERSION_FNV, None);
3543 assert_eq!(
3544 older, current,
3545 "a version 1 transport reads as a version 2 one does"
3546 );
3547 assert!(
3548 !current
3549 .as_ref()
3550 .err()
3551 .is_some_and(|error| error.contains("checksum")),
3552 "{current:?}"
3553 );
3554 let corrupted = read("checksum-bad", TRANSPORT_VERSION, Some(0)).unwrap_err();
3555 assert!(corrupted.contains("checksum does not match"), "{corrupted}");
3556 let unknown = read("checksum-v3", TRANSPORT_VERSION + 1, None).unwrap_err();
3557 assert!(unknown.contains("version"), "{unknown}");
3558 }
3559
3560 #[test]
3561 fn mixed_pytest_and_unittest_runs_have_distinct_runner_limitations() {
3562 let obligations = build_python_obligations("m.py", "def f():\n return 1\n").unwrap();
3563 let directory = temporary("mixed-runners");
3564 let mut records = vec![json!({"t":"process","v":1,"run":"run-1","pid":1,
3565 "worker":"main","python":"3.11.11","executable":"python","argv":["pytest"]})];
3566 for (index, runner) in [PYTEST_RUNNER, UNITTEST_RUNNER].iter().enumerate() {
3567 let test = format!("tests/test_m.py::test_{runner}");
3568 records.push(
3569 json!({"t":"phase","ctx":index+1,"at":index+5,"worker":"main",
3570 "test":test,"retry":0,"phase":"call"}),
3571 );
3572 records.push(json!({"t":"hit","ctx":index+1,"id":obligations.plan.statements[0].id}));
3573 records.push(json!({"t":"outcome","worker":"main","test":test,"retry":0,
3574 "phase":"call","outcome":"passed","xfail":false,"runner":runner}));
3575 }
3576 write_transport(&directory.join("main.1.mmap"), &records, 0);
3577 let run = build_python_frontend_run(
3578 &obligations.manifest,
3579 &directory,
3580 "run-1",
3581 "now",
3582 0,
3583 Path::new(""),
3584 )
3585 .unwrap();
3586 assert_eq!(run.tests, 2);
3587 assert_eq!(run.declaration.runners.len(), 2);
3588 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
3589 for runner in &run.declaration.runners {
3590 assert_eq!(runner.limitations.len(), 1);
3591 assert_eq!(runner.attribution.action, AttributionPrecision::Unavailable);
3592 assert_eq!(
3593 runner.limitations[0].scopes,
3594 [FrontendLimitationScope::Action]
3595 );
3596 }
3597 fs::remove_dir_all(directory).unwrap();
3598 }
3599
3600 #[test]
3601 fn a_phase_its_runner_reported_more_than_once_is_one_phase() {
3602 let source = "def f(a):\n return a\n";
3610 let obligations = build_python_obligations("m.py", source).unwrap();
3611 let directory = temporary("subtests");
3612 let subtest = |outcome: &str| {
3613 json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_many","retry":0,
3614 "phase":"call","outcome":outcome,"xfail":false})
3615 };
3616 let lines = [
3617 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"python","argv":["pytest"]}),
3618 json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_many","retry":0,"phase":"call"}),
3619 json!({"t":"hit","ctx":1,"id":obligations.plan.statements[0].id}),
3620 json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_many","retry":0,"phase":"setup","outcome":"passed","xfail":false}),
3621 subtest("passed"),
3622 subtest("passed"),
3623 subtest("failed"),
3624 subtest("passed"),
3625 json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_many","retry":0,"phase":"teardown","outcome":"passed","xfail":false}),
3626 json!({"t":"exit","at":9}),
3627 ];
3628 write_transport(&directory.join("main.1.mmap"), &lines, 0);
3629 let run = build_python_frontend_run(
3630 &obligations.manifest,
3631 &directory,
3632 "run-1",
3633 "now",
3634 1,
3635 Path::new(""),
3636 )
3637 .unwrap();
3638 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
3641 let test = &run.request.raw_results[0];
3642 assert_eq!(
3643 test.phases.len(),
3644 3,
3645 "setup, one call, teardown: {:?}",
3646 test.phases
3647 );
3648 assert_eq!(
3649 test.phases
3650 .iter()
3651 .filter(|phase| phase.kind == "test")
3652 .count(),
3653 1,
3654 "the subtests share the call phase their coverage was recorded under"
3655 );
3656 assert_eq!(
3657 test.status.as_deref(),
3658 Some("failed"),
3659 "a subtest that failed fails its test"
3660 );
3661 assert_eq!(test.runtime.len(), 1);
3663 assert!(!test.runtime[0].hits.is_empty());
3664 fs::remove_dir_all(directory).unwrap();
3665 }
3666
3667 #[test]
3668 fn fails_closed_without_an_interpreter_or_tests() {
3669 let obligations = build_python_obligations("m.py", "x = 1\n").unwrap();
3670 let directory = temporary("empty");
3671 assert!(matches!(
3672 build_python_frontend_run(
3673 &obligations.manifest,
3674 &directory,
3675 "run-1",
3676 "now",
3677 0,
3678 Path::new("")
3679 ),
3680 Err(PythonEvidenceError::NoInterpreter)
3681 ));
3682 let path = directory.join("main.1.mmap");
3683 write_transport(
3684 &path,
3685 &[
3686 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"p","argv":[]}),
3687 ],
3688 0,
3689 );
3690 assert!(matches!(
3691 build_python_frontend_run(
3692 &obligations.manifest,
3693 &directory,
3694 "run-1",
3695 "now",
3696 0,
3697 Path::new("")
3698 ),
3699 Err(PythonEvidenceError::NoTests)
3700 ));
3701 let mut torn = fs::read(&path).unwrap();
3705 let process_length = transport_u32(&torn, TRANSPORT_HEADER_SIZE + 4).unwrap() as usize;
3706 let torn_cursor =
3707 align_transport(TRANSPORT_HEADER_SIZE + TRANSPORT_RECORD_HEADER_SIZE + process_length)
3708 .unwrap();
3709 torn[torn_cursor + 4..torn_cursor + 8].copy_from_slice(&5_u32.to_le_bytes());
3710 torn[torn_cursor + TRANSPORT_RECORD_HEADER_SIZE
3711 ..torn_cursor + TRANSPORT_RECORD_HEADER_SIZE + 5]
3712 .copy_from_slice(b"{nope");
3713 fs::write(&path, torn).unwrap();
3714 assert!(matches!(
3715 build_python_frontend_run(
3716 &obligations.manifest,
3717 &directory,
3718 "run-1",
3719 "now",
3720 0,
3721 Path::new("")
3722 ),
3723 Err(PythonEvidenceError::NoTests)
3724 ));
3725 write_transport(
3726 &path,
3727 &[
3728 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.8.20","executable":"p","argv":[]}),
3729 ],
3730 0,
3731 );
3732 assert!(matches!(
3733 build_python_frontend_run(
3734 &obligations.manifest,
3735 &directory,
3736 "run-1",
3737 "now",
3738 0,
3739 Path::new("")
3740 ),
3741 Err(PythonEvidenceError::UnsupportedPython(_))
3742 ));
3743 write_transport(
3744 &path,
3745 &[
3746 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"p","argv":[]}),
3747 ],
3748 2,
3749 );
3750 assert!(matches!(
3751 build_python_frontend_run(
3752 &obligations.manifest,
3753 &directory,
3754 "run-1",
3755 "now",
3756 0,
3757 Path::new("")
3758 ),
3759 Err(PythonEvidenceError::DroppedRecords { count: 2, .. })
3760 ));
3761 fs::remove_dir_all(directory).unwrap();
3762 }
3763}