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