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