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