1use std::{
12 collections::{BTreeMap, BTreeSet},
13 fs::{self, File},
14 path::{Component, Path},
15};
16
17use memmap2::MmapOptions;
18use serde::Deserialize;
19use serde_json::json;
20use sha2::{Digest, Sha256};
21use supercov_contracts::{
22 AttributionPrecision, ExecutionModel, FrontendAttribution, FrontendLimitation,
23 FrontendLimitationScope, FrontendRunDeclaration, FrontendRunnerDeclaration,
24 LANGUAGE_FRONTEND_PROTOCOL_VERSION, StructuralSource,
25};
26
27use crate::{
28 coverage_analysis::McdcVector,
29 coverage_report::{
30 CoverageManifest, CoverageModelDeclaration, CoveragePhase, CoverageReportRequest,
31 DecisionMeta, DecisionSnapshot, ExecutionScope, ExitCodeInput, PersistedCoverageModel,
32 RawTestResult, RuntimeEvent, RuntimeSnapshot, TestProvenance,
33 },
34 evidence_archive::EvidenceArchiveEntry,
35};
36
37pub const RUBY_EVIDENCE_VERSION: u32 = 1;
38pub const RUBY_FRONTEND_VERSION: &str = "ruby-coverage-v1";
39pub const RSPEC_RUNNER: &str = "rspec";
40pub const MINITEST_RUNNER: &str = "minitest";
41pub const TEST_UNIT_RUNNER: &str = "test-unit";
42pub const CUCUMBER_RUNNER: &str = "cucumber";
43
44const TRANSPORT_MAGIC: &[u8; 8] = b"SCVRUBY1";
45const TRANSPORT_VERSION: u32 = 1;
46const TRANSPORT_HEADER_SIZE: usize = 64;
47const TRANSPORT_RECORD_HEADER_SIZE: usize = 16;
48const TRANSPORT_MAX_RECORD_SIZE: usize = 4 * 1024 * 1024;
49
50fn default_runner() -> String {
51 RSPEC_RUNNER.into()
52}
53
54fn ruby_runner(runner: &str) -> bool {
55 matches!(
56 runner,
57 RSPEC_RUNNER | MINITEST_RUNNER | TEST_UNIT_RUNNER | CUCUMBER_RUNNER
58 )
59}
60
61use crate::served_tests::{REQUEST_WORKER, ServedTest};
62
63#[derive(Debug, Deserialize)]
66#[allow(dead_code)]
67#[serde(tag = "t", rename_all = "lowercase", deny_unknown_fields)]
68enum Record {
69 Process {
70 v: u32,
71 run: String,
72 pid: u64,
73 worker: String,
74 ruby: String,
75 executable: String,
76 argv: Vec<String>,
77 #[serde(default)]
81 lines: Option<String>,
82 },
83 Worker {
84 worker: String,
85 },
86 Lines {
89 mode: String,
90 },
91 Phase {
92 ctx: u64,
93 at: i64,
94 worker: String,
95 test: String,
96 retry: usize,
97 phase: String,
98 },
99 Outcome {
100 worker: String,
101 test: String,
102 retry: usize,
103 phase: String,
104 outcome: String,
105 xfail: bool,
106 #[serde(default = "default_runner")]
107 runner: String,
108 #[serde(default)]
111 file: Option<String>,
112 #[serde(default)]
115 tags: Vec<String>,
116 #[serde(default)]
119 title: Option<String>,
120 },
121 Hit {
122 ctx: u64,
123 id: String,
124 },
125 Ids {
128 first: usize,
129 names: Vec<String>,
130 },
131 Hits {
133 ctx: u64,
134 n: Vec<usize>,
135 },
136 Dec {
137 ctx: u64,
138 id: String,
139 v: String,
140 o: u8,
141 },
142 Assert {
145 ctx: u64,
146 },
147 Limitation {
148 id: String,
149 reason: String,
150 #[serde(default)]
151 file: Option<String>,
152 #[serde(default)]
153 obligation: Option<String>,
154 },
155 Exit {
156 at: i64,
157 },
158}
159
160#[derive(Debug)]
161pub enum RubyEvidenceError {
162 Io(String),
163 UnsafeEntry(String),
164 InvalidRecord {
165 file: String,
166 line: usize,
167 reason: String,
168 },
169 InvalidTransport {
170 file: String,
171 reason: String,
172 },
173 DroppedRecords {
174 file: String,
175 count: u64,
176 },
177 RunMismatch {
178 expected: String,
179 actual: String,
180 },
181 UnsupportedVersion(u32),
182 UnknownContext {
183 file: String,
184 line: usize,
185 context: u64,
186 },
187 UnknownObligation(String),
188 InvalidVector {
189 id: String,
190 expected: usize,
191 actual: usize,
192 },
193 NoInterpreter,
194 NoTests,
195 UnsupportedRuby(String),
196}
197
198impl std::fmt::Display for RubyEvidenceError {
199 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200 match self {
201 Self::Io(reason) => write!(formatter, "could not read Ruby evidence: {reason}"),
202 Self::UnsafeEntry(name) => write!(formatter, "unsafe Ruby evidence entry: {name}"),
203 Self::InvalidRecord { file, line, reason } => {
204 write!(formatter, "invalid Ruby evidence record {file}:{line}: {reason}")
205 }
206 Self::InvalidTransport { file, reason } => {
207 write!(formatter, "invalid Ruby evidence transport {file}: {reason}")
208 }
209 Self::DroppedRecords { file, count } => write!(
210 formatter,
211 "Ruby evidence transport {file} exhausted its bounded capacity and dropped {count} record(s)"
212 ),
213 Self::RunMismatch { expected, actual } => write!(
214 formatter,
215 "Ruby evidence belongs to run {actual}, expected {expected}"
216 ),
217 Self::UnsupportedVersion(version) => {
218 write!(formatter, "unsupported Ruby evidence version {version}")
219 }
220 Self::UnknownContext { file, line, context } => write!(
221 formatter,
222 "Ruby evidence {file}:{line} references undeclared context {context}"
223 ),
224 Self::UnknownObligation(id) => {
225 write!(formatter, "Ruby runtime reported an unknown obligation: {id}")
226 }
227 Self::InvalidVector {
228 id,
229 expected,
230 actual,
231 } => write!(
232 formatter,
233 "Ruby decision {id} reported {actual} condition values, expected {expected}"
234 ),
235 Self::NoInterpreter => formatter.write_str(
236 "no Supercov-hooked Ruby interpreter ran: the test command did not start Ruby 3.3+ with Supercov's RUBYOPT hook (RUBYOPT may be cleared by the command, or the runner is not Ruby)",
237 ),
238 Self::NoTests => formatter.write_str(
239 "the Ruby run produced no test outcomes; Supercov measures Ruby through RSpec, Minitest, test-unit and Cucumber",
240 ),
241 Self::UnsupportedRuby(version) => write!(
242 formatter,
243 "Supercov measures Ruby 3.3 or newer; the test command ran Ruby {version}"
244 ),
245 }
246 }
247}
248
249impl std::error::Error for RubyEvidenceError {}
250
251fn stable_id(prefix: &str, values: &[&str]) -> String {
252 let mut hash = Sha256::new();
253 for value in values {
254 hash.update(value.as_bytes());
255 hash.update([0]);
256 }
257 let digest = hash.finalize();
258 let mut encoded = String::with_capacity(prefix.len() + 25);
259 encoded.push_str(prefix);
260 encoded.push(':');
261 for byte in &digest[..12] {
262 use std::fmt::Write as _;
263 write!(&mut encoded, "{byte:02x}").expect("string formatting");
264 }
265 encoded
266}
267
268#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
269struct Identity {
270 worker: String,
271 test: String,
272 retry: usize,
273 phase: String,
274}
275
276type ObservedVectors = BTreeSet<(Vec<Option<bool>>, bool)>;
277type OutcomesByAttempt = BTreeMap<(String, String, usize), Vec<(String, String, bool)>>;
279type RunnersByAttempt = BTreeMap<(String, String, usize), String>;
281type TestFilesByAttempt = BTreeMap<(String, String, usize), String>;
283type TestTagsByAttempt = BTreeMap<(String, String, usize), Vec<String>>;
285type TestTitlesByAttempt = BTreeMap<(String, String, usize), String>;
287fn relative(root: &Path, path: &str) -> Option<String> {
291 let candidate = Path::new(path);
292 let relative = if candidate.is_absolute() {
293 candidate.strip_prefix(root).ok()?
294 } else {
295 candidate.strip_prefix("./").unwrap_or(candidate)
296 };
297 let text = relative.to_string_lossy().replace('\\', "/");
298 (!text.is_empty() && !text.starts_with("../")).then_some(text)
299}
300
301#[derive(Debug, Default)]
302struct Observations {
303 hits: BTreeSet<String>,
304 vectors: BTreeMap<String, ObservedVectors>,
305}
306
307#[derive(Debug, Clone)]
308struct RuntimeLimitation {
309 id: String,
310 reason: String,
311 file: Option<String>,
312 obligation: Option<String>,
313}
314
315#[derive(Debug, Default)]
316struct Evidence {
317 interpreters: usize,
318 ruby_versions: BTreeSet<String>,
319 per_identity: BTreeMap<Identity, Observations>,
320 background: BTreeMap<String, Observations>,
321 outcomes: OutcomesByAttempt,
322 runners: RunnersByAttempt,
323 test_files: TestFilesByAttempt,
324 test_tags: TestTagsByAttempt,
325 test_titles: TestTitlesByAttempt,
326 limitations: Vec<RuntimeLimitation>,
327 first_sighting: bool,
329 requests: Vec<(Identity, i64, usize, String, String)>,
333 served_by: BTreeMap<String, BTreeSet<(String, String, usize)>>,
336 phase_starts: Vec<crate::served_tests::PhaseStart>,
339}
340
341fn read_evidence_directory(directory: &Path, run_id: &str) -> Result<Evidence, RubyEvidenceError> {
342 let mut evidence = Evidence::default();
343 let mut files = match fs::read_dir(directory) {
344 Ok(entries) => entries
345 .collect::<Result<Vec<_>, _>>()
346 .map_err(|error| RubyEvidenceError::Io(error.to_string()))?,
347 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
348 Err(error) => return Err(RubyEvidenceError::Io(error.to_string())),
349 };
350 files.sort_by_key(|entry| entry.file_name());
351 for entry in files {
352 let name = entry
353 .file_name()
354 .into_string()
355 .map_err(|_| RubyEvidenceError::UnsafeEntry("<non-utf8>".into()))?;
356 if Path::new(&name)
357 .components()
358 .any(|component| !matches!(component, Component::Normal(_)))
359 || !name.ends_with(".mmap")
360 {
361 return Err(RubyEvidenceError::UnsafeEntry(name));
362 }
363 let metadata = fs::symlink_metadata(entry.path())
364 .map_err(|error| RubyEvidenceError::Io(error.to_string()))?;
365 if !metadata.file_type().is_file() {
366 return Err(RubyEvidenceError::UnsafeEntry(name));
367 }
368 let file =
369 File::open(entry.path()).map_err(|error| RubyEvidenceError::Io(error.to_string()))?;
370 let contents = unsafe { MmapOptions::new().map(&file) }
374 .map_err(|error| RubyEvidenceError::Io(error.to_string()))?;
375 read_evidence_file(&name, &contents, run_id, &mut evidence)?;
376 }
377 Ok(evidence)
378}
379
380fn transport_u32(bytes: &[u8], offset: usize) -> Option<u32> {
381 bytes
382 .get(offset..offset + 4)
383 .and_then(|value| value.try_into().ok())
384 .map(u32::from_le_bytes)
385}
386
387fn transport_u64(bytes: &[u8], offset: usize) -> Option<u64> {
388 bytes
389 .get(offset..offset + 8)
390 .and_then(|value| value.try_into().ok())
391 .map(u64::from_le_bytes)
392}
393
394fn transport_checksum(payload: &[u8]) -> u32 {
395 payload.iter().fold(0x811c_9dc5_u32, |value, byte| {
396 (value ^ u32::from(*byte)).wrapping_mul(0x0100_0193)
397 })
398}
399
400fn align_transport(value: usize) -> Option<usize> {
401 value.checked_add(7).map(|value| value & !7)
402}
403
404fn checked_u32(bytes: &[u8], offset: usize) -> u32 {
406 transport_u32(bytes, offset).expect("the header was checked to hold this field")
407}
408
409fn checked_u64(bytes: &[u8], offset: usize) -> u64 {
410 transport_u64(bytes, offset).expect("the header was checked to hold this field")
411}
412
413fn read_evidence_file(
414 name: &str,
415 contents: &[u8],
416 run_id: &str,
417 evidence: &mut Evidence,
418) -> Result<(), RubyEvidenceError> {
419 let invalid_transport = |reason: &str| RubyEvidenceError::InvalidTransport {
420 file: name.into(),
421 reason: reason.into(),
422 };
423 if contents.len() < TRANSPORT_HEADER_SIZE
424 || contents.get(..8) != Some(TRANSPORT_MAGIC.as_slice())
425 || transport_u32(contents, 8) != Some(TRANSPORT_VERSION)
426 || transport_u32(contents, 12) != Some(TRANSPORT_HEADER_SIZE as u32)
427 {
428 return Err(invalid_transport("header or version does not match"));
429 }
430 let declared_capacity = checked_u64(contents, 16);
431 if declared_capacity < TRANSPORT_HEADER_SIZE as u64 || declared_capacity > contents.len() as u64
432 {
433 return Err(invalid_transport(
434 "declared capacity is outside the mapped file",
435 ));
436 }
437 let dropped = checked_u64(contents, 24);
438 if dropped != 0 {
439 return Err(RubyEvidenceError::DroppedRecords {
440 file: name.into(),
441 count: dropped,
442 });
443 }
444 let transport_pid = checked_u64(contents, 32);
445 if transport_pid == 0 {
446 return Err(invalid_transport("process id is missing"));
447 }
448 let mut contexts = BTreeMap::<u64, Identity>::new();
449 let mut before_assertion = BTreeMap::<u64, Observations>::new();
452 let mut process_worker: Option<String> = None;
453 let mut interned = Vec::<String>::new();
455 let mut process_started = false;
456 let mut process_reported = false;
457 let mut cursor = TRANSPORT_HEADER_SIZE;
458 let mut record_index = 0;
459 while cursor + TRANSPORT_RECORD_HEADER_SIZE <= contents.len() {
460 let commit = contents[cursor];
461 if commit == 0 {
462 break;
465 }
466 record_index += 1;
467 let line_number = record_index;
468 let invalid = |reason: &str| RubyEvidenceError::InvalidRecord {
469 file: name.into(),
470 line: line_number,
471 reason: reason.into(),
472 };
473 if commit != 1
474 || contents[cursor + 1..cursor + 4] != [0, 0, 0]
475 || contents[cursor + 12..cursor + 16] != [0, 0, 0, 0]
476 {
477 return Err(invalid("commit marker or reserved bytes are invalid"));
478 }
479 let length = checked_u32(contents, cursor + 4) as usize;
480 if length == 0 || length > TRANSPORT_MAX_RECORD_SIZE {
481 return Err(invalid("payload length is outside the transport bound"));
482 }
483 let payload_start = cursor + TRANSPORT_RECORD_HEADER_SIZE;
484 let payload_end = payload_start
485 .checked_add(length)
486 .filter(|end| *end <= contents.len())
487 .ok_or_else(|| invalid("payload extends past the mapped file"))?;
488 let next_cursor = align_transport(payload_end)
489 .filter(|end| *end <= contents.len())
490 .ok_or_else(|| invalid("aligned frame extends past the mapped file"))?;
491 if contents[payload_end..next_cursor]
492 .iter()
493 .any(|byte| *byte != 0)
494 {
495 return Err(invalid("frame padding is not zero"));
496 }
497 let payload = &contents[payload_start..payload_end];
498 let expected_checksum = checked_u32(contents, cursor + 8);
499 if transport_checksum(payload) != expected_checksum {
500 return Err(invalid("payload checksum does not match"));
501 }
502 let record: Record =
503 serde_json::from_slice(payload).map_err(|error| RubyEvidenceError::InvalidRecord {
504 file: name.into(),
505 line: line_number,
506 reason: error.to_string(),
507 })?;
508 match record {
509 Record::Process {
510 v,
511 run,
512 pid,
513 worker,
514 ruby,
515 lines,
516 ..
517 } => {
518 if lines.as_deref() != Some("exact") {
519 evidence.first_sighting = true;
520 }
521 if v != RUBY_EVIDENCE_VERSION {
522 return Err(RubyEvidenceError::UnsupportedVersion(v));
523 }
524 if run != run_id {
525 return Err(RubyEvidenceError::RunMismatch {
526 expected: run_id.into(),
527 actual: run,
528 });
529 }
530 if pid != transport_pid {
531 return Err(invalid("process record does not match the transport owner"));
532 }
533 let supported = ruby
534 .split('.')
535 .take(2)
536 .map(|part| part.parse::<u32>().ok())
537 .collect::<Option<Vec<_>>>()
538 .is_some_and(|parts| parts.len() == 2 && (parts[0], parts[1]) >= (3, 3));
539 if !supported {
540 return Err(RubyEvidenceError::UnsupportedRuby(ruby));
541 }
542 evidence.interpreters += 1;
543 evidence.ruby_versions.insert(ruby);
544 process_started = true;
545 process_worker = Some(worker);
546 }
547 Record::Worker { worker } => process_worker = Some(worker),
548 Record::Lines { mode } => {
549 if mode != "exact" {
550 evidence.first_sighting = true;
551 }
552 }
553 Record::Phase {
554 ctx,
555 at,
556 worker,
557 test,
558 retry,
559 phase,
560 } => {
561 if ctx == 0 {
562 return Err(invalid("phase context 0 is reserved for background"));
563 }
564 if !matches!(phase.as_str(), "setup" | "call" | "teardown") {
565 return Err(invalid("unknown test phase"));
566 }
567 if test.trim().is_empty() || worker.trim().is_empty() {
568 return Err(invalid("phase identity must name a worker and test"));
569 }
570 let worker = if worker == REQUEST_WORKER {
573 format!("{REQUEST_WORKER}:{name}")
574 } else {
575 worker
576 };
577 let identity = Identity {
578 worker,
579 test,
580 retry,
581 phase,
582 };
583 if identity.worker.starts_with(REQUEST_WORKER) {
584 evidence.requests.push((
585 identity.clone(),
586 at,
587 line_number,
588 name.to_owned(),
589 process_worker.clone().unwrap_or_else(|| "main".into()),
590 ));
591 } else {
592 if identity.phase == "call" {
593 before_assertion.insert(ctx, Observations::default());
594 }
595 let attempt = (
596 identity.worker.clone(),
597 identity.test.clone(),
598 identity.retry,
599 );
600 evidence
601 .phase_starts
602 .push((name.to_owned(), line_number, attempt.clone()));
603 evidence
604 .served_by
605 .entry(name.to_owned())
606 .or_default()
607 .insert(attempt);
608 }
609 contexts.insert(ctx, identity);
610 }
611 Record::Outcome {
612 worker,
613 test,
614 retry,
615 phase,
616 outcome,
617 xfail,
618 runner,
619 file,
620 tags,
621 title,
622 } => {
623 if !matches!(phase.as_str(), "setup" | "call" | "teardown") {
624 return Err(invalid("unknown test outcome phase"));
625 }
626 if !matches!(
627 outcome.as_str(),
628 "passed" | "failed" | "skipped" | "rerun" | "error"
629 ) {
630 return Err(invalid("unknown test outcome"));
631 }
632 if !matches!(
633 runner.as_str(),
634 RSPEC_RUNNER | MINITEST_RUNNER | TEST_UNIT_RUNNER | CUCUMBER_RUNNER
635 ) {
636 return Err(invalid("unknown Ruby test runner"));
637 }
638 let key = (worker, test, retry);
639 if let Some(previous) = evidence.runners.get(&key)
640 && previous != &runner
641 {
642 return Err(invalid("one attempt was reported by two runners"));
643 }
644 evidence.runners.insert(key.clone(), runner);
645 if let Some(file) = file.filter(|path| !path.is_empty()) {
646 evidence.test_files.entry(key.clone()).or_insert(file);
647 }
648 if let Some(title) = title.filter(|title| !title.is_empty()) {
649 evidence.test_titles.entry(key.clone()).or_insert(title);
650 }
651 if !tags.is_empty() {
652 let known = evidence.test_tags.entry(key.clone()).or_default();
653 for tag in tags {
654 if !known.contains(&tag) {
655 known.push(tag);
656 }
657 }
658 }
659 fold_outcome(
660 evidence.outcomes.entry(key).or_default(),
661 phase,
662 outcome,
663 xfail,
664 );
665 }
666 Record::Hit { ctx, id } => {
667 if let Some(before) = before_assertion.get_mut(&ctx) {
668 before.hits.insert(id.clone());
669 }
670 observations(
671 evidence,
672 &contexts,
673 process_worker.as_deref(),
674 ctx,
675 name,
676 line_number,
677 )?
678 .hits
679 .insert(id);
680 }
681 Record::Ids { first, names } => {
682 if first != interned.len() {
683 return Err(invalid("id names must continue where the last ones ended"));
684 }
685 interned.extend(names);
686 }
687 Record::Hits { ctx, n } => {
688 let ids = n
689 .iter()
690 .map(|&number| interned.get(number).cloned())
691 .collect::<Option<Vec<_>>>()
692 .ok_or_else(|| invalid("hit number has no name"))?;
693 if let Some(before) = before_assertion.get_mut(&ctx) {
694 before.hits.extend(ids.iter().cloned());
695 }
696 observations(
697 evidence,
698 &contexts,
699 process_worker.as_deref(),
700 ctx,
701 name,
702 line_number,
703 )?
704 .hits
705 .extend(ids);
706 }
707 Record::Dec { ctx, id, v, o } => {
708 if v.is_empty() || !v.bytes().all(|digit| matches!(digit, b'0' | b'1' | b'2')) {
709 return Err(invalid("decision vector digits must be 0, 1 or 2"));
710 }
711 if o > 1 {
712 return Err(invalid("decision outcome must be 0 or 1"));
713 }
714 let values = v
715 .bytes()
716 .map(|digit| match digit {
717 b'0' => None,
718 b'1' => Some(false),
719 _ => Some(true),
720 })
721 .collect::<Vec<_>>();
722 if let Some(before) = before_assertion.get_mut(&ctx) {
723 before
724 .vectors
725 .entry(id.clone())
726 .or_default()
727 .insert((values.clone(), o == 1));
728 }
729 observations(
730 evidence,
731 &contexts,
732 process_worker.as_deref(),
733 ctx,
734 name,
735 line_number,
736 )?
737 .vectors
738 .entry(id)
739 .or_default()
740 .insert((values, o == 1));
741 }
742 Record::Assert { ctx } => {
743 if let Some(before) = before_assertion.remove(&ctx) {
746 let identity = contexts
747 .get(&ctx)
748 .ok_or(RubyEvidenceError::UnknownContext {
749 file: name.into(),
750 line: line_number,
751 context: ctx,
752 })?;
753 let asserted = evidence
754 .per_identity
755 .entry(Identity {
756 phase: "assertion".into(),
757 ..identity.clone()
758 })
759 .or_default();
760 asserted.hits.extend(before.hits);
761 for (id, vectors) in before.vectors {
762 asserted.vectors.entry(id).or_default().extend(vectors);
763 }
764 }
765 }
766 Record::Limitation {
767 id,
768 reason,
769 file,
770 obligation,
771 } => evidence.limitations.push(RuntimeLimitation {
772 id,
773 reason,
774 file,
775 obligation,
776 }),
777 Record::Exit { .. } => process_reported = true,
778 }
779 cursor = next_cursor;
780 }
781 if process_started && !process_reported {
790 evidence.limitations.push(RuntimeLimitation {
791 id: "ruby-process-did-not-report".into(),
792 reason: format!(
793 "an interpreter process (pid {transport_pid}) ended without reporting, so line, branch and method observations it made after its last test boundary are missing; a process killed with SIGKILL, or one that left through exit!, cannot flush them"
794 ),
795 file: None,
796 obligation: None,
797 });
798 }
799 Ok(())
800}
801
802fn observations<'a>(
803 evidence: &'a mut Evidence,
804 contexts: &BTreeMap<u64, Identity>,
805 process_worker: Option<&str>,
806 context: u64,
807 file: &str,
808 line: usize,
809) -> Result<&'a mut Observations, RubyEvidenceError> {
810 if context == 0 {
811 return Ok(evidence
812 .background
813 .entry(process_worker.unwrap_or("main").to_owned())
814 .or_default());
815 }
816 let identity = contexts
817 .get(&context)
818 .ok_or(RubyEvidenceError::UnknownContext {
819 file: file.into(),
820 line,
821 context,
822 })?;
823 Ok(evidence.per_identity.entry(identity.clone()).or_default())
824}
825
826pub fn ruby_coverage_model() -> CoverageModelDeclaration {
827 CoverageModelDeclaration {
828 language: "ruby".into(),
829 variant: "ruby-owned-coverage".into(),
830 name: "ruby-coverage-probes-v1".into(),
831 completeness_meaning: "Every statement, method, decision vector, loop, short-circuit, case and rescue obligation Supercov derived from the source was observed through Ruby's Coverage module or a load-time probe with exact test identity; the declared limitations remain separate.".into(),
832 measured: vec![
833 "executable statements proven by Ruby's line coverage, or a probe when a line holds several".into(),
834 "method definitions entered (Ruby's method coverage)".into(),
835 "if/unless/elsif/ternary/while/until decisions with masking MC/DC vectors from operand probes".into(),
836 "while, until, for and iterator-block (each, map, times, ...) zero-versus-entered iteration".into(),
837 "&&, ||, ||= and &&= short-circuit alternatives".into(),
838 "case/when and case/in clause selection, safe navigation".into(),
839 "begin/rescue completion, handler selection and exception propagation".into(),
840 "RSpec, Minitest, test-unit and Cucumber worker, test and setup/call/teardown phase identity".into(),
841 "evidence a test recorded before its first assertion, linked to that assertion when the test passes".into(),
842 ],
843 not_measured: vec![
844 "blocks and lambdas as function entry points (they are statements inside their methods)".into(),
845 "blocks passed by reference (map(&:to_s)) as loops: they have no block body to observe".into(),
846 "line, branch and method observations made while test phases overlapped in threads (attributed to the run; probe observations stay per test)".into(),
847 "causal linkage to individual actions, or to any assertion after a test's first".into(),
848 "code compiled from strings at runtime (eval, instance_eval with strings)".into(),
849 "child coverage outside Process.spawn, Kernel#spawn, Kernel#system and fork".into(),
850 "all input values, semantic partitions, paths, or concurrency interleavings".into(),
851 "mutation score or assertion fault-detection strength".into(),
852 ],
853 }
854}
855
856fn phase_id(run: &str, identity: &Identity) -> String {
857 stable_id(
858 "ruby-phase",
859 &[
860 run,
861 &identity.worker,
862 &identity.test,
863 &identity.retry.to_string(),
864 &identity.phase,
865 ],
866 )
867}
868
869fn scope(run: &str, worker: &str, test: &str, retry: usize) -> ExecutionScope {
870 ExecutionScope {
871 version: 1,
872 run_id: run.into(),
873 worker_id: worker.into(),
874 test_id: test.into(),
875 test_key: stable_id("ruby-test", &[worker, test]),
876 retry,
877 attempt_id: stable_id("ruby-attempt", &[run, worker, test, &retry.to_string()]),
878 }
879}
880
881struct ManifestIndex<'a> {
882 points: BTreeSet<&'a str>,
883 alternatives: BTreeSet<&'a str>,
884 decisions: BTreeMap<&'a str, &'a DecisionMeta>,
885 lines: BTreeMap<&'a str, (String, usize)>,
886 sources: BTreeMap<&'a str, &'a str>,
887}
888
889impl<'a> ManifestIndex<'a> {
890 fn new(manifest: &'a CoverageManifest) -> Self {
891 let mut lines = BTreeMap::new();
892 let mut sources = BTreeMap::new();
893 for point in &manifest.points {
894 lines.insert(point.id.as_str(), (point.file.clone(), point.line));
895 sources.insert(point.id.as_str(), point.source.as_str());
896 }
897 for decision in &manifest.decisions {
898 lines.insert(decision.id.as_str(), (decision.file.clone(), decision.line));
899 sources.insert(decision.id.as_str(), decision.source.as_str());
900 }
901 for branch in &manifest.branches {
902 lines.insert(branch.id.as_str(), (branch.file.clone(), branch.line));
903 sources.insert(branch.id.as_str(), branch.source.as_str());
904 }
905 Self {
906 points: manifest
907 .points
908 .iter()
909 .map(|point| point.id.as_str())
910 .collect(),
911 alternatives: manifest
912 .branches
913 .iter()
914 .flat_map(|branch| branch.alternatives.iter().map(|alt| alt.id.as_str()))
915 .collect(),
916 decisions: manifest
917 .decisions
918 .iter()
919 .map(|decision| (decision.id.as_str(), decision))
920 .collect(),
921 lines,
922 sources,
923 }
924 }
925}
926
927fn snapshot(
928 index: &ManifestIndex<'_>,
929 observations: &Observations,
930 phase: &str,
931) -> Result<RuntimeSnapshot, RubyEvidenceError> {
932 let mut hits = BTreeSet::new();
933 for id in &observations.hits {
934 if !index.points.contains(id.as_str()) && !index.alternatives.contains(id.as_str()) {
935 return Err(RubyEvidenceError::UnknownObligation(id.clone()));
936 }
937 hits.insert(id.clone());
938 }
939 let mut decisions = Vec::new();
940 let mut events = Vec::new();
941 let mut clock = 1;
942 for id in &hits {
943 events.push(RuntimeEvent {
944 event_type: "hit".into(),
945 id: id.clone(),
946 vector: None,
947 timestamp_ms: clock,
948 phase_id: Some(phase.into()),
949 statement_id: None,
950 environment: "ruby".into(),
951 });
952 clock += 1;
953 }
954 for (id, vectors) in &observations.vectors {
955 let Some(meta) = index.decisions.get(id.as_str()) else {
956 return Err(RubyEvidenceError::UnknownObligation(id.clone()));
957 };
958 let mut observed = Vec::new();
959 for (values, outcome) in vectors {
960 if values.len() != meta.conditions.len() {
961 return Err(RubyEvidenceError::InvalidVector {
962 id: id.clone(),
963 expected: meta.conditions.len(),
964 actual: values.len(),
965 });
966 }
967 let vector = McdcVector {
968 values: values.clone(),
969 outcome: *outcome,
970 };
971 events.push(RuntimeEvent {
972 event_type: "decision".into(),
973 id: id.clone(),
974 vector: Some(vector.clone()),
975 timestamp_ms: clock,
976 phase_id: Some(phase.into()),
977 statement_id: None,
978 environment: "ruby".into(),
979 });
980 clock += 1;
981 observed.push(vector);
982 }
983 decisions.push(DecisionSnapshot {
984 meta: (*meta).clone(),
985 vectors: observed,
986 });
987 }
988 Ok(RuntimeSnapshot {
989 decisions,
990 hits: hits.into_iter().collect(),
991 events,
992 logicals: Vec::new(),
993 phase_id: None,
994 })
995}
996
997fn fold_outcome(
1004 outcomes: &mut Vec<(String, String, bool)>,
1005 phase: String,
1006 outcome: String,
1007 xfail: bool,
1008) {
1009 let Some(reported) = outcomes.iter_mut().find(|(name, _, _)| *name == phase) else {
1010 outcomes.push((phase, outcome, xfail));
1011 return;
1012 };
1013 if outcome_severity(&outcome) > outcome_severity(&reported.1) {
1014 reported.1 = outcome;
1015 }
1016 reported.2 |= xfail;
1017}
1018
1019fn outcome_severity(outcome: &str) -> u8 {
1021 match outcome {
1022 "failed" | "rerun" | "error" => 2,
1023 "skipped" => 1,
1024 _ => 0,
1025 }
1026}
1027
1028fn attempt_status(outcomes: &[(String, String, bool)]) -> String {
1029 if outcomes
1030 .iter()
1031 .any(|(_, outcome, _)| matches!(outcome.as_str(), "failed" | "rerun" | "error"))
1032 {
1033 "failed"
1034 } else if outcomes.iter().any(|(_, outcome, _)| outcome == "skipped") {
1035 "skipped"
1036 } else {
1037 "passed"
1038 }
1039 .into()
1040}
1041
1042#[derive(Debug, Clone, PartialEq)]
1043pub struct RubyFrontendRun {
1044 pub declaration: FrontendRunDeclaration,
1045 pub request: CoverageReportRequest,
1046 pub tests: usize,
1047 pub interpreters: usize,
1048 pub ruby_versions: Vec<String>,
1049}
1050
1051impl RubyFrontendRun {
1052 pub fn archive_entries(&self) -> Result<Vec<EvidenceArchiveEntry>, serde_json::Error> {
1053 let model = PersistedCoverageModel::from_declaration(
1054 self.request
1055 .coverage_model
1056 .as_ref()
1057 .expect("Ruby frontend always declares a coverage model"),
1058 )
1059 .expect("Ruby coverage model is contract-valid");
1060 let mut entries = vec![
1061 EvidenceArchiveEntry {
1062 path: "coverage-model.json".into(),
1063 contents: serde_json::to_vec(&model)?,
1064 },
1065 EvidenceArchiveEntry {
1066 path: "frontend.json".into(),
1067 contents: serde_json::to_vec(&self.declaration)?,
1068 },
1069 EvidenceArchiveEntry {
1070 path: "manifest.json".into(),
1071 contents: serde_json::to_vec(&self.request.manifest)?,
1072 },
1073 ];
1074 for (index, result) in self.request.raw_results.iter().enumerate() {
1075 entries.push(EvidenceArchiveEntry {
1076 path: format!("results/{index:08}/mcdc.json"),
1077 contents: serde_json::to_vec(result)?,
1078 });
1079 }
1080 Ok(entries)
1081 }
1082}
1083
1084pub fn build_ruby_frontend_run(
1087 manifest: &CoverageManifest,
1088 evidence_directory: &Path,
1089 run_id: &str,
1090 generated_at: &str,
1091 test_exit_code: i32,
1092 root: &Path,
1093) -> Result<RubyFrontendRun, RubyEvidenceError> {
1094 let mut evidence = read_evidence_directory(evidence_directory, run_id)?;
1095 let order = crate::served_tests::InProcess::new(&evidence.phase_starts);
1099 let by_time = credit_requests_by_time(&mut evidence, |_, transport, _, seq| {
1100 order.owner(transport, seq).cloned()
1101 });
1102 let mut run = build_from_evidence(
1103 evidence,
1104 manifest,
1105 run_id,
1106 generated_at,
1107 test_exit_code,
1108 root,
1109 )?;
1110 for raw in &mut run.request.raw_results {
1111 let Some(scope) = &raw.scope else { continue };
1112 let key = (
1113 scope.worker_id.clone(),
1114 scope.test_id.clone(),
1115 raw.retry.unwrap_or(0),
1116 );
1117 if by_time.contains(&key) && raw.attribution == crate::coverage_report::ATTRIBUTION_EXACT {
1119 raw.attribution = crate::coverage_report::ATTRIBUTION_BY_TIME.into();
1120 }
1121 }
1122 Ok(run)
1123}
1124
1125pub fn build_ruby_server_run(
1129 manifest: &CoverageManifest,
1130 evidence_directory: &Path,
1131 run_id: &str,
1132 generated_at: &str,
1133 test_exit_code: i32,
1134 root: &Path,
1135 served: &[ServedTest],
1136) -> Result<RubyFrontendRun, RubyEvidenceError> {
1137 let mut evidence = read_evidence_directory(evidence_directory, run_id)?;
1138 let mut by_attempt = BTreeMap::new();
1139 for test in served {
1140 let key = (test.worker.clone(), test.test_id.clone(), test.retry);
1141 let outcome = match test.status.as_str() {
1142 "passed" => "passed",
1143 "skipped" => "skipped",
1144 _ => "failed",
1145 };
1146 evidence.outcomes.entry(key.clone()).or_default().push((
1147 "call".into(),
1148 outcome.into(),
1149 test.expected_status == "failed",
1150 ));
1151 evidence
1152 .runners
1153 .insert(key.clone(), test.provenance.runner.clone());
1154 by_attempt.insert(key, test);
1155 }
1156 let by_time = credit_requests_by_time(&mut evidence, |evidence, transport, at, _| {
1157 crate::served_tests::owner(served, evidence.served_by.get(transport), at)
1158 .map(|test| (test.worker.clone(), test.test_id.clone(), test.retry))
1159 });
1160 let mut run = build_from_evidence(
1161 evidence,
1162 manifest,
1163 run_id,
1164 generated_at,
1165 test_exit_code,
1166 root,
1167 )?;
1168 for raw in &mut run.request.raw_results {
1169 let Some(test) = raw.scope.as_ref().and_then(|scope| {
1170 by_attempt.get(&(
1171 scope.worker_id.clone(),
1172 raw.test.clone(),
1173 raw.retry.unwrap_or(0),
1174 ))
1175 }) else {
1176 continue;
1177 };
1178 raw.test = test.name.clone();
1179 raw.title = Some(test.title.clone());
1180 raw.test_file = Some(test.file.clone());
1181 raw.status = Some(test.status.clone());
1182 raw.expected_status = Some(test.expected_status.clone());
1183 raw.provenance = test.provenance.clone();
1184 if by_time.contains(&(test.worker.clone(), test.test_id.clone(), test.retry))
1186 && raw.attribution == crate::coverage_report::ATTRIBUTION_EXACT
1187 {
1188 raw.attribution = crate::coverage_report::ATTRIBUTION_BY_TIME.into();
1189 }
1190 }
1191 run.tests = run
1192 .request
1193 .raw_results
1194 .iter()
1195 .filter(|raw| raw.role == "test")
1196 .map(|raw| raw.test.as_str())
1197 .collect::<BTreeSet<_>>()
1198 .len();
1199 Ok(run)
1200}
1201
1202fn credit_requests_by_time(
1207 evidence: &mut Evidence,
1208 owner: impl Fn(&Evidence, &str, i64, usize) -> Option<(String, String, usize)>,
1209) -> BTreeSet<(String, String, usize)> {
1210 let requests = std::mem::take(&mut evidence.requests);
1211 let owners = requests
1212 .iter()
1213 .map(|(_, at, seq, transport, _)| owner(evidence, transport, *at, *seq))
1214 .collect::<Vec<_>>();
1215 let mut credited = BTreeSet::new();
1216 for ((identity, _, _, _, process_worker), owner) in requests.into_iter().zip(owners) {
1217 let Some(observations) = evidence.per_identity.remove(&identity) else {
1218 continue;
1219 };
1220 let into = match owner {
1221 Some((worker, test, retry)) => {
1222 credited.insert((worker.clone(), test.clone(), retry));
1223 evidence
1224 .per_identity
1225 .entry(Identity {
1226 worker,
1227 test,
1228 retry,
1229 phase: "call".into(),
1230 })
1231 .or_default()
1232 }
1233 None => evidence.background.entry(process_worker).or_default(),
1234 };
1235 into.hits.extend(observations.hits);
1236 for (id, vectors) in observations.vectors {
1237 into.vectors.entry(id).or_default().extend(vectors);
1238 }
1239 }
1240 credited
1241}
1242
1243fn build_from_evidence(
1244 evidence: Evidence,
1245 manifest: &CoverageManifest,
1246 run_id: &str,
1247 generated_at: &str,
1248 test_exit_code: i32,
1249 root: &Path,
1250) -> Result<RubyFrontendRun, RubyEvidenceError> {
1251 if evidence.interpreters == 0 {
1252 return Err(RubyEvidenceError::NoInterpreter);
1253 }
1254 if evidence.outcomes.is_empty() {
1255 return Err(RubyEvidenceError::NoTests);
1256 }
1257 let Evidence {
1258 interpreters,
1259 ruby_versions,
1260 per_identity,
1261 background,
1262 outcomes,
1263 runners,
1264 test_files,
1265 test_tags,
1266 test_titles,
1267 limitations,
1268 first_sighting,
1269 ..
1270 } = evidence;
1271 let attribution = if first_sighting {
1272 crate::coverage_report::ATTRIBUTION_PARTIAL
1273 } else {
1274 crate::coverage_report::ATTRIBUTION_EXACT
1275 };
1276 let mut manifest = manifest.clone();
1277 let index = ManifestIndex::new(&manifest);
1278
1279 let mut raw_results = Vec::new();
1280 let mut observed_runners = BTreeSet::new();
1281 let mut identities_by_attempt =
1282 BTreeMap::<(String, String, usize), Vec<(&Identity, &Observations)>>::new();
1283 for (identity, observations) in &per_identity {
1284 identities_by_attempt
1285 .entry((
1286 identity.worker.clone(),
1287 identity.test.clone(),
1288 identity.retry,
1289 ))
1290 .or_default()
1291 .push((identity, observations));
1292 }
1293 for ((worker, test, retry), mut outcomes) in outcomes {
1294 let runner = runners
1295 .get(&(worker.clone(), test.clone(), retry))
1296 .cloned()
1297 .unwrap_or_else(default_runner);
1298 let attempt_identities = identities_by_attempt
1299 .remove(&(worker.clone(), test.clone(), retry))
1300 .unwrap_or_default();
1301 observed_runners.insert(runner.clone());
1302 outcomes.sort_by_key(|(phase, _, _)| match phase.as_str() {
1303 "setup" => 0,
1304 "call" => 1,
1305 _ => 2,
1306 });
1307 let mut phases = Vec::new();
1308 let mut runtime = Vec::new();
1309 let mut observed_phases = BTreeSet::new();
1310 for (position, (phase_name, outcome, xfail)) in outcomes.iter().enumerate() {
1311 observed_phases.insert(phase_name.clone());
1312 let identity = Identity {
1313 worker: worker.clone(),
1314 test: test.clone(),
1315 retry,
1316 phase: phase_name.clone(),
1317 };
1318 let id = phase_id(run_id, &identity);
1319 phases.push(CoveragePhase {
1320 id: id.clone(),
1321 kind: match phase_name.as_str() {
1322 "call" => "test",
1323 value => value,
1324 }
1325 .into(),
1326 operation: format!("{runner} {phase_name}"),
1327 source: Some(test.clone()),
1328 caused_by_phase_id: None,
1329 started_at_ms: position as i64 * 2 + 1,
1330 ended_at_ms: Some(position as i64 * 2 + 2),
1331 status: Some(match outcome.as_str() {
1332 "rerun" | "error" => "failed".into(),
1333 value => value.into(),
1334 }),
1335 error: None,
1336 });
1337 if let Some((_, observations)) = attempt_identities
1338 .iter()
1339 .find(|(candidate, _)| candidate.phase == phase_name.as_str())
1340 {
1341 runtime.push(snapshot(&index, observations, &id)?);
1342 }
1343 if phase_name != "call" {
1344 continue;
1345 }
1346 if let Some((identity, observations)) = attempt_identities
1350 .iter()
1351 .find(|(candidate, _)| candidate.phase == "assertion")
1352 {
1353 observed_phases.insert("assertion".to_owned());
1354 let id = phase_id(run_id, identity);
1355 phases.push(CoveragePhase {
1356 id: id.clone(),
1357 kind: "assertion".into(),
1358 operation: format!("{runner} assertion"),
1359 source: Some(test.clone()),
1360 caused_by_phase_id: None,
1361 started_at_ms: position as i64 * 2 + 1,
1362 ended_at_ms: Some(position as i64 * 2 + 2),
1363 status: Some(
1364 if outcome == "passed" && !*xfail {
1365 "passed"
1366 } else {
1367 "failed"
1368 }
1369 .into(),
1370 ),
1371 error: None,
1372 });
1373 runtime.push(snapshot(&index, observations, &id)?);
1374 }
1375 }
1376 for (identity, observations) in attempt_identities {
1379 if !observed_phases.contains(&identity.phase) {
1380 let id = phase_id(run_id, identity);
1381 phases.push(CoveragePhase {
1382 id: id.clone(),
1383 kind: match identity.phase.as_str() {
1384 "call" => "test",
1385 value => value,
1386 }
1387 .into(),
1388 operation: format!("{runner} {}", identity.phase),
1389 source: Some(test.clone()),
1390 caused_by_phase_id: None,
1391 started_at_ms: phases.len() as i64 * 2 + 1,
1392 ended_at_ms: None,
1393 status: Some("failed".into()),
1394 error: Some("the phase started but the runner reported no outcome".into()),
1395 });
1396 runtime.push(snapshot(&index, observations, &id)?);
1397 }
1398 }
1399 let status = if phases.iter().any(|phase| phase.error.is_some()) {
1400 "failed".into()
1401 } else {
1402 attempt_status(&outcomes)
1403 };
1404 let test_file = test_files
1405 .get(&(worker.clone(), test.clone(), retry))
1406 .and_then(|path| relative(root, path))
1407 .or_else(|| test.split("::").next().map(str::to_owned));
1408 raw_results.push(RawTestResult {
1409 test_id: Some(test.clone()),
1410 scope: Some(scope(run_id, &worker, &test, retry)),
1411 test: test.clone(),
1412 test_file: test_file.clone(),
1416 title: test_titles
1417 .get(&(worker.clone(), test.clone(), retry))
1418 .cloned()
1419 .or_else(|| test.rsplit("::").next().map(str::to_owned)),
1420 retry: Some(retry),
1421 status: Some(status),
1422 expected_status: Some(
1423 if outcomes.iter().any(|(_, _, xfail)| *xfail) {
1424 "failed"
1425 } else {
1426 "passed"
1427 }
1428 .into(),
1429 ),
1430 flaky: false,
1431 provenance: {
1432 let tags = test_tags
1433 .get(&(worker.clone(), test.clone(), retry))
1434 .cloned()
1435 .unwrap_or_default();
1436 TestProvenance {
1437 runner: runner.clone(),
1438 kind: crate::test_kind::with_tags(test_file.as_deref(), &tags).into(),
1439 project: None,
1440 source: RUBY_FRONTEND_VERSION.into(),
1441 tags,
1442 }
1443 },
1444 role: "test".into(),
1445 attribution: attribution.into(),
1451 phases,
1452 runtime,
1453 browser: Vec::new(),
1454 server: Vec::new(),
1455 });
1456 }
1457 let default_observed = observed_runners
1460 .iter()
1461 .next()
1462 .cloned()
1463 .unwrap_or_else(default_runner);
1464 for ((worker, test, retry), identities) in identities_by_attempt {
1465 let runner = default_observed.clone();
1466 let mut phases = Vec::new();
1467 let mut runtime = Vec::new();
1468 for (position, (identity, observations)) in identities.iter().enumerate() {
1469 let id = phase_id(run_id, identity);
1470 phases.push(CoveragePhase {
1471 id: id.clone(),
1472 kind: match identity.phase.as_str() {
1473 "call" => "test",
1474 value => value,
1475 }
1476 .into(),
1477 operation: format!("{runner} {}", identity.phase),
1478 source: Some(test.clone()),
1479 caused_by_phase_id: None,
1480 started_at_ms: position as i64 * 2 + 1,
1481 ended_at_ms: None,
1482 status: Some("failed".into()),
1483 error: Some("the phase started but the runner reported no outcome".into()),
1484 });
1485 runtime.push(snapshot(&index, observations, &id)?);
1486 }
1487 let test_file = test_files
1488 .get(&(worker.clone(), test.clone(), retry))
1489 .and_then(|path| relative(root, path))
1490 .or_else(|| test.split("::").next().map(str::to_owned));
1491 raw_results.push(RawTestResult {
1492 test_id: Some(test.clone()),
1493 scope: Some(scope(run_id, &worker, &test, retry)),
1494 test: test.clone(),
1495 test_file: test_file.clone(),
1499 title: test_titles
1500 .get(&(worker.clone(), test.clone(), retry))
1501 .cloned()
1502 .or_else(|| test.rsplit("::").next().map(str::to_owned)),
1503 retry: Some(retry),
1504 status: Some("failed".into()),
1505 expected_status: Some("passed".into()),
1506 flaky: false,
1507 provenance: {
1508 let tags = test_tags
1509 .get(&(worker.clone(), test.clone(), retry))
1510 .cloned()
1511 .unwrap_or_default();
1512 TestProvenance {
1513 runner: runner.clone(),
1514 kind: crate::test_kind::with_tags(test_file.as_deref(), &tags).into(),
1515 project: None,
1516 source: RUBY_FRONTEND_VERSION.into(),
1517 tags,
1518 }
1519 },
1520 role: "test".into(),
1521 attribution: attribution.into(),
1527 phases,
1528 runtime,
1529 browser: Vec::new(),
1530 server: Vec::new(),
1531 });
1532 }
1533 for (worker, observations) in &background {
1534 if observations.hits.is_empty() && observations.vectors.is_empty() {
1535 continue;
1536 }
1537 let test = format!("__supercov_background__:{worker}");
1538 let identity = Identity {
1539 worker: worker.clone(),
1540 test: test.clone(),
1541 retry: 0,
1542 phase: "background".into(),
1543 };
1544 let phase = phase_id(run_id, &identity);
1545 raw_results.push(RawTestResult {
1546 test_id: Some(test.clone()),
1547 scope: Some(scope(run_id, worker, &test, 0)),
1548 test: "Ruby load and background execution".into(),
1549 test_file: None,
1550 title: None,
1551 retry: Some(0),
1552 status: Some("unknown".into()),
1553 expected_status: None,
1554 flaky: false,
1555 provenance: TestProvenance {
1556 runner: default_observed.clone(),
1557 kind: "background".into(),
1558 project: None,
1559 source: RUBY_FRONTEND_VERSION.into(),
1560 tags: Vec::new(),
1561 },
1562 role: "background".into(),
1563 attribution: crate::coverage_report::ATTRIBUTION_EXACT.into(),
1564 phases: vec![CoveragePhase {
1565 id: phase.clone(),
1566 kind: "background".into(),
1567 operation: "Ruby load background".into(),
1568 source: None,
1569 caused_by_phase_id: None,
1570 started_at_ms: 0,
1571 ended_at_ms: Some(0),
1572 status: Some("passed".into()),
1573 error: None,
1574 }],
1575 runtime: vec![snapshot(&index, observations, &phase)?],
1576 browser: Vec::new(),
1577 server: Vec::new(),
1578 });
1579 }
1580
1581 let mut limitation_ids = manifest
1585 .limitations
1586 .iter()
1587 .filter_map(|item| item.get("id").and_then(serde_json::Value::as_str))
1588 .map(str::to_owned)
1589 .collect::<BTreeSet<_>>();
1590 let mut unmeasured = manifest.unmeasured.iter().cloned().collect::<BTreeSet<_>>();
1591 let mut new_limitations = Vec::new();
1592 for limitation in &limitations {
1593 if let Some(obligation) = &limitation.obligation {
1594 if !index.lines.contains_key(obligation.as_str()) {
1595 return Err(RubyEvidenceError::UnknownObligation(obligation.clone()));
1596 }
1597 unmeasured.insert(obligation.clone());
1598 } else if let Some(file) = &limitation.file {
1599 unmeasured.extend(
1604 index
1605 .lines
1606 .iter()
1607 .filter(|(_, (obligation_file, _))| obligation_file == file)
1608 .map(|(id, _)| (*id).to_owned()),
1609 );
1610 }
1611 if limitation_ids.insert(limitation.id.clone()) {
1612 let (file, line) = limitation
1613 .obligation
1614 .as_deref()
1615 .and_then(|id| index.lines.get(id).cloned())
1616 .unwrap_or_else(|| {
1617 (
1618 limitation.file.clone().unwrap_or_else(|| {
1619 manifest
1620 .points
1621 .first()
1622 .map_or(".".into(), |point| point.file.clone())
1623 }),
1624 1,
1625 )
1626 });
1627 let source = limitation
1628 .obligation
1629 .as_deref()
1630 .and_then(|id| index.sources.get(id))
1631 .map(|source| source.lines().next().unwrap_or_default().to_owned())
1632 .unwrap_or_default();
1633 new_limitations.push(json!({
1634 "id": limitation.id,
1635 "kind": "semantic-safety",
1636 "file": file,
1637 "line": line,
1638 "column": 0,
1639 "source": source,
1640 "reason": limitation.reason
1641 }));
1642 }
1643 }
1644 manifest.limitations.extend(new_limitations);
1645 manifest.unmeasured = unmeasured.into_iter().collect();
1646 let structural_limitations = limitation_ids.into_iter().collect::<Vec<_>>();
1647
1648 let tests = raw_results
1652 .iter()
1653 .filter(|raw| raw.role == "test")
1654 .map(|raw| raw.test.as_str())
1655 .collect::<BTreeSet<_>>()
1656 .len();
1657 Ok(RubyFrontendRun {
1658 declaration: FrontendRunDeclaration {
1659 protocol_version: LANGUAGE_FRONTEND_PROTOCOL_VERSION,
1660 frontend_id: "ruby".into(),
1661 frontend_version: RUBY_FRONTEND_VERSION.into(),
1662 language: "ruby".into(),
1663 structural_source: StructuralSource::OwnedProbes,
1664 runners: observed_runners
1665 .iter()
1666 .map(|runner| FrontendRunnerDeclaration {
1667 runner: runner.clone(),
1668 execution_model: ExecutionModel::SerialInProcess,
1669 attribution: FrontendAttribution {
1670 run: AttributionPrecision::Exact,
1671 worker: AttributionPrecision::Exact,
1672 test: AttributionPrecision::Exact,
1673 retry: AttributionPrecision::Exact,
1674 phase: AttributionPrecision::Exact,
1675 action: AttributionPrecision::Unavailable,
1676 assertion: if ruby_runner(runner) {
1679 AttributionPrecision::Exact
1680 } else {
1681 AttributionPrecision::Unavailable
1682 },
1683 },
1684 limitations: vec![if ruby_runner(runner) {
1685 FrontendLimitation {
1686 id: format!("ruby-{runner}-action-linkage"),
1687 scopes: vec![FrontendLimitationScope::Action],
1688 reason: format!("{runner} exposes no general action lifecycle"),
1689 }
1690 } else {
1691 FrontendLimitation {
1692 id: format!("ruby-server-{runner}-linkage"),
1693 scopes: vec![
1694 FrontendLimitationScope::Action,
1695 FrontendLimitationScope::Assertion,
1696 ],
1697 reason: format!(
1698 "the server sees each {runner} test's requests, not its actions or assertions"
1699 ),
1700 }
1701 }]
1702 .into_iter()
1703 .chain(first_sighting.then(|| FrontendLimitation {
1711 id: format!("ruby-{runner}-first-sighting-lines"),
1712 scopes: vec![FrontendLimitationScope::Test],
1713 reason:
1714 "some lines came from Ruby's one-shot line coverage (code loaded without Supercov's probes), which records a line for the first test that reaches it, so a test's coverage is a lower bound and the run's is its upper one"
1715 .into(),
1716 }))
1717 .collect(),
1718 })
1719 .collect(),
1720 structural_limitations,
1721 },
1722 request: CoverageReportRequest {
1723 run_id: run_id.into(),
1724 manifest,
1725 raw_results,
1726 generated_at: generated_at.into(),
1727 coverage_model: Some(ruby_coverage_model()),
1728 integrity: None,
1729 test_exit_code: ExitCodeInput::Present(Some(test_exit_code)),
1730 },
1731 tests,
1732 interpreters,
1733 ruby_versions: ruby_versions.into_iter().collect(),
1734 })
1735}
1736
1737#[cfg(test)]
1738mod tests {
1739 use super::*;
1740 use crate::{
1741 coverage_analysis::PointKind, frontend_protocol::validate_frontend_report_request,
1742 ruby_instrumenter::build_ruby_obligations,
1743 };
1744
1745 fn frame(payload: &[u8]) -> Vec<u8> {
1746 let mut bytes = vec![1u8, 0, 0, 0];
1747 bytes.extend_from_slice(&(payload.len() as u32).to_le_bytes());
1748 bytes.extend_from_slice(&transport_checksum(payload).to_le_bytes());
1749 bytes.extend_from_slice(&[0, 0, 0, 0]);
1750 bytes.extend_from_slice(payload);
1751 while bytes.len() % 8 != 0 {
1752 bytes.push(0);
1753 }
1754 bytes
1755 }
1756
1757 fn transport(records: &[serde_json::Value]) -> Vec<u8> {
1758 let mut body = Vec::new();
1759 for record in records {
1760 body.extend(frame(record.to_string().as_bytes()));
1761 }
1762 let capacity = TRANSPORT_HEADER_SIZE + body.len();
1763 let mut bytes = vec![0u8; TRANSPORT_HEADER_SIZE];
1764 bytes[..8].copy_from_slice(TRANSPORT_MAGIC);
1765 bytes[8..12].copy_from_slice(&TRANSPORT_VERSION.to_le_bytes());
1766 bytes[12..16].copy_from_slice(&(TRANSPORT_HEADER_SIZE as u32).to_le_bytes());
1767 bytes[16..24].copy_from_slice(&(capacity as u64).to_le_bytes());
1768 bytes[32..40].copy_from_slice(&7u64.to_le_bytes());
1769 bytes.extend(body);
1770 bytes
1771 }
1772
1773 #[test]
1774 fn a_damaged_transport_is_refused_with_what_is_wrong() {
1775 let process = serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["x.rb"]});
1778 let exit = serde_json::json!({"t":"exit","at":9});
1779 let valid = transport(&[process.clone(), exit.clone()]);
1780 let read = |bytes: &[u8]| match read_evidence_file(
1781 "main.7.a.mmap",
1782 bytes,
1783 "run-1",
1784 &mut Evidence::default(),
1785 ) {
1786 Ok(()) => "accepted".to_owned(),
1787 Err(error) => error.to_string(),
1788 };
1789 assert_eq!(read(&valid), "accepted");
1790 let payload_end = 80 + process.to_string().len();
1792 assert_ne!(payload_end % 8, 0, "the frame has padding to corrupt");
1793 let set = |at: usize, value: &[u8]| {
1794 let mut bytes = valid.clone();
1795 bytes[at..at + value.len()].copy_from_slice(value);
1796 bytes
1797 };
1798 let mut truncated = valid[..payload_end].to_vec();
1799 truncated[16..24].copy_from_slice(&(payload_end as u64).to_le_bytes());
1800 let other_owner = transport(&[
1801 serde_json::json!({"t":"process","v":1,"run":"run-1","pid":8,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["x.rb"]}),
1802 ]);
1803 let other_run = transport(&[
1804 serde_json::json!({"t":"process","v":1,"run":"run-2","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["x.rb"]}),
1805 ]);
1806 for (bytes, reason) in [
1807 (set(0, b"X"), "header or version does not match"),
1808 (
1809 set(16, &8_u64.to_le_bytes()),
1810 "declared capacity is outside the mapped file",
1811 ),
1812 (set(24, &3_u64.to_le_bytes()), "3"),
1813 (set(32, &0_u64.to_le_bytes()), "process id is missing"),
1814 (set(64, &[2]), "commit marker or reserved bytes are invalid"),
1815 (
1816 set(68, &0_u32.to_le_bytes()),
1817 "payload length is outside the transport bound",
1818 ),
1819 (
1820 set(68, &4096_u32.to_le_bytes()),
1821 "payload extends past the mapped file",
1822 ),
1823 (truncated, "aligned frame extends past the mapped file"),
1824 (set(payload_end, &[1]), "frame padding is not zero"),
1825 (
1826 set(72, &0_u32.to_le_bytes()),
1827 "payload checksum does not match",
1828 ),
1829 (other_owner, "does not match the transport owner"),
1830 (other_run, "run-2"),
1831 ] {
1832 let said = read(&bytes);
1833 assert!(said.contains(reason), "{reason}: {said}");
1834 }
1835 }
1836
1837 fn temporary(name: &str) -> std::path::PathBuf {
1838 let nonce = std::time::SystemTime::now()
1839 .duration_since(std::time::UNIX_EPOCH)
1840 .unwrap()
1841 .as_nanos();
1842 let path = std::env::temp_dir().join(format!(
1843 "supercov-ruby-evidence-{}-{nonce}-{name}",
1844 std::process::id()
1845 ));
1846 fs::create_dir_all(&path).unwrap();
1847 path
1848 }
1849
1850 #[test]
1851 fn declares_the_gap_when_a_process_never_reported() {
1852 let source = "def f(a)\n a\nend\n";
1859 let mut probe = 0;
1860 let obligations =
1861 build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
1862 let statement = obligations
1863 .manifest
1864 .points
1865 .iter()
1866 .find(|point| point.kind == PointKind::Statement)
1867 .unwrap();
1868 let base = [
1869 serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["child.rb"]}),
1870 serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
1871 serde_json::json!({"t":"hit","ctx":1,"id":statement.id}),
1872 serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":"passed","xfail":false,"runner":"minitest"}),
1873 ];
1874
1875 let killed = temporary("killed");
1876 fs::write(killed.join("main.7.a.mmap"), transport(&base)).unwrap();
1877 let run = build_ruby_frontend_run(
1878 &obligations.manifest,
1879 &killed,
1880 "run-1",
1881 "now",
1882 0,
1883 Path::new(""),
1884 )
1885 .unwrap();
1886 let declared = serde_json::to_string(&run.request.manifest.limitations).unwrap();
1887 assert!(
1888 declared.contains("ruby-process-did-not-report"),
1889 "a process that never reported must declare the gap: {declared}"
1890 );
1891 fs::remove_dir_all(&killed).unwrap();
1892
1893 let mut clean = base.to_vec();
1894 clean.push(serde_json::json!({"t":"exit","at":9}));
1895 let reported = temporary("reported");
1896 fs::write(reported.join("main.7.a.mmap"), transport(&clean)).unwrap();
1897 let run = build_ruby_frontend_run(
1898 &obligations.manifest,
1899 &reported,
1900 "run-1",
1901 "now",
1902 0,
1903 Path::new(""),
1904 )
1905 .unwrap();
1906 let declared = serde_json::to_string(&run.request.manifest.limitations).unwrap();
1907 assert!(
1908 !declared.contains("ruby-process-did-not-report"),
1909 "a process that reported cleanly must declare nothing: {declared}"
1910 );
1911 fs::remove_dir_all(&reported).unwrap();
1912 }
1913
1914 fn under(first: &str, rest: &str) -> String {
1919 let mut path = std::path::PathBuf::from(if cfg!(windows) {
1920 format!("C:\\{first}")
1921 } else {
1922 format!("/{first}")
1923 });
1924 for part in rest.split('/').filter(|part| !part.is_empty()) {
1925 path.push(part);
1926 }
1927 path.to_string_lossy().into_owned()
1928 }
1929
1930 fn assertion_sources(run: &RubyFrontendRun) -> Vec<String> {
1931 run.request.raw_results[0]
1932 .phases
1933 .iter()
1934 .filter(|phase| phase.kind == "assertion")
1935 .filter_map(|phase| phase.source.clone())
1936 .collect()
1937 }
1938
1939 fn run_with_sites(
1940 name: &str,
1941 sites: &[serde_json::Value],
1942 outcome_file: Option<&str>,
1943 root: &str,
1944 ) -> RubyFrontendRun {
1945 let source = "def f(a)\n a\nend\n";
1946 let mut probe = 0;
1947 let obligations =
1948 build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
1949 let mut outcome = serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":"passed","xfail":false,"runner":"minitest"});
1950 if let Some(file) = outcome_file {
1951 outcome["file"] = serde_json::json!(file);
1952 }
1953 let mut records = vec![
1954 serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["test.rb"]}),
1955 serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
1956 serde_json::json!({"t":"assert","ctx":1}),
1957 ];
1958 records.extend(sites.iter().cloned());
1959 records.push(outcome);
1960 records.push(serde_json::json!({"t":"exit","at":9}));
1961 let directory = temporary(name);
1962 fs::write(directory.join("main.7.a.mmap"), transport(&records)).unwrap();
1963 let run = build_ruby_frontend_run(
1964 &obligations.manifest,
1965 &directory,
1966 "run-1",
1967 "now",
1968 0,
1969 Path::new(root),
1970 )
1971 .unwrap();
1972 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
1973 fs::remove_dir_all(directory).unwrap();
1974 run
1975 }
1976
1977 #[test]
1978 fn a_test_s_assertions_are_its_one_assertion_phase() {
1979 let run = run_with_sites("ruby-assertion-phase", &[], None, &under("project", ""));
1982 assert_eq!(assertion_sources(&run), vec!["MTest#test_x".to_string()]);
1983 }
1984
1985 #[test]
1986 fn the_runner_names_the_test_file_in_either_path_form() {
1987 let root = under("project", "");
1991 for reported in [
1992 under("project", "test/m_test.rb"),
1993 "test/m_test.rb".to_owned(),
1994 "./test/m_test.rb".to_owned(),
1995 ] {
1996 let run = run_with_sites("asite-file", &[], Some(reported.as_str()), &root);
1997 assert_eq!(
1998 run.request.raw_results[0].test_file.as_deref(),
1999 Some("test/m_test.rb"),
2000 "{reported} should resolve to the project path"
2001 );
2002 }
2003 let without = run_with_sites("asite-nofile", &[], None, &root);
2004 assert_eq!(
2005 without.request.raw_results[0].test_file.as_deref(),
2006 Some("MTest#test_x"),
2007 "an adapter that cannot name a file keeps the identity fallback"
2008 );
2009 }
2010
2011 #[test]
2012 fn a_phase_a_runner_reported_more_than_once_is_one_phase() {
2013 let source = "def f(a)\n a\nend\n";
2017 let mut probe = 0;
2018 let obligations =
2019 build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
2020 let statement = obligations
2021 .manifest
2022 .points
2023 .iter()
2024 .find(|point| point.kind == PointKind::Statement)
2025 .unwrap();
2026 let again = |outcome: &str| {
2027 serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,
2028 "phase":"call","outcome":outcome,"xfail":false,"runner":"minitest"})
2029 };
2030 let records = [
2031 serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["test.rb"]}),
2032 serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
2033 serde_json::json!({"t":"hit","ctx":1,"id":statement.id}),
2034 again("passed"),
2035 again("failed"),
2036 again("passed"),
2037 serde_json::json!({"t":"exit","at":9}),
2038 ];
2039 let directory = temporary("repeated-phase");
2040 fs::write(directory.join("main.7.a.mmap"), transport(&records)).unwrap();
2041 let run = build_ruby_frontend_run(
2042 &obligations.manifest,
2043 &directory,
2044 "run-1",
2045 "now",
2046 1,
2047 Path::new(""),
2048 )
2049 .unwrap();
2050 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
2051 let test = &run.request.raw_results[0];
2052 assert_eq!(test.phases.len(), 1, "{:?}", test.phases);
2053 assert_eq!(test.status.as_deref(), Some("failed"));
2054 fs::remove_dir_all(directory).unwrap();
2055 }
2056
2057 #[test]
2058 fn evidence_before_the_first_assertion_links_to_it_when_the_test_passes() {
2059 let source = "def f(a)\n a\nend\n\ndef g(b)\n b\nend\n";
2064 let mut probe = 0;
2065 let obligations =
2066 build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
2067 let statements: Vec<_> = obligations
2068 .manifest
2069 .points
2070 .iter()
2071 .filter(|point| point.kind == PointKind::Statement)
2072 .collect();
2073 let (before, after) = (&statements[0].id, &statements[1].id);
2074 let run_with = |name: &str, outcome: &str| {
2075 let records = [
2076 serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["test.rb"]}),
2077 serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
2078 serde_json::json!({"t":"hit","ctx":1,"id":before}),
2079 serde_json::json!({"t":"assert","ctx":1}),
2080 serde_json::json!({"t":"assert","ctx":1}),
2081 serde_json::json!({"t":"hit","ctx":1,"id":after}),
2082 serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":outcome,"xfail":false,"runner":"minitest"}),
2083 serde_json::json!({"t":"exit","at":9}),
2084 ];
2085 let directory = temporary(name);
2086 fs::write(directory.join("main.7.a.mmap"), transport(&records)).unwrap();
2087 let run = build_ruby_frontend_run(
2088 &obligations.manifest,
2089 &directory,
2090 "run-1",
2091 "now",
2092 0,
2093 Path::new(""),
2094 )
2095 .unwrap();
2096 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
2097 fs::remove_dir_all(directory).unwrap();
2098 run
2099 };
2100 let events_of = |result: &RawTestResult, phase: &str| -> BTreeSet<String> {
2101 result
2102 .runtime
2103 .iter()
2104 .flat_map(|snapshot| snapshot.events.iter())
2105 .filter(|event| event.phase_id.as_deref() == Some(phase))
2106 .map(|event| event.id.clone())
2107 .collect()
2108 };
2109
2110 let run = run_with("asserted-passed", "passed");
2111 let passed = &run.request.raw_results[0];
2112 assert_eq!(passed.test, "MTest#test_x");
2113 let assertion = passed
2114 .phases
2115 .iter()
2116 .find(|phase| phase.kind == "assertion")
2117 .expect("the asserting test carries an assertion phase");
2118 assert_eq!(assertion.status.as_deref(), Some("passed"));
2119 assert_eq!(
2120 events_of(passed, &assertion.id),
2121 BTreeSet::from([before.clone()]),
2122 "only what ran before the marker is the assertion's evidence"
2123 );
2124 let test_phase = passed
2125 .phases
2126 .iter()
2127 .find(|phase| phase.kind == "test")
2128 .unwrap();
2129 assert_eq!(
2130 events_of(passed, &test_phase.id),
2131 BTreeSet::from([before.clone(), after.clone()]),
2132 "the test phase keeps everything it ran"
2133 );
2134 assert_eq!(
2135 run.declaration.runners[0].attribution.assertion,
2136 AttributionPrecision::Exact
2137 );
2138
2139 let run = run_with("asserted-failed", "failed");
2140 let failed = &run.request.raw_results[0];
2141 let assertion = failed
2142 .phases
2143 .iter()
2144 .find(|phase| phase.kind == "assertion")
2145 .unwrap();
2146 assert_eq!(
2147 assertion.status.as_deref(),
2148 Some("failed"),
2149 "a failed test's assertion witnessed nothing"
2150 );
2151 }
2152
2153 #[test]
2154 fn joins_rspec_and_minitest_outcomes_into_exact_results() {
2155 let source = "def f(a, b)\n if a && b\n 1\n else\n 0\n end\nend\n";
2156 let mut probe = 0;
2157 let obligations =
2158 build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
2159 let decision = &obligations.manifest.decisions[0];
2160 let statement = obligations
2161 .manifest
2162 .points
2163 .iter()
2164 .find(|point| point.kind == PointKind::Statement)
2165 .unwrap();
2166 let directory = temporary("join");
2167 let records = [
2168 serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["rspec"]}),
2169 serde_json::json!({"t":"hit","ctx":0,"id":statement.id}),
2170 serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"call"}),
2171 serde_json::json!({"t":"dec","ctx":1,"id":decision.id,"v":"22","o":1}),
2172 serde_json::json!({"t":"outcome","worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"setup","outcome":"passed","xfail":false,"runner":"rspec"}),
2173 serde_json::json!({"t":"outcome","worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"call","outcome":"passed","xfail":false,"runner":"rspec"}),
2174 serde_json::json!({"t":"outcome","worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"teardown","outcome":"passed","xfail":false,"runner":"rspec"}),
2175 serde_json::json!({"t":"phase","ctx":2,"at":6,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
2176 serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":"skipped","xfail":false,"runner":"minitest"}),
2177 serde_json::json!({"t":"exit","at":9}),
2178 ];
2179 fs::write(directory.join("main.7.a.mmap"), transport(&records)).unwrap();
2180 let run = build_ruby_frontend_run(
2181 &obligations.manifest,
2182 &directory,
2183 "run-1",
2184 "now",
2185 0,
2186 Path::new(""),
2187 )
2188 .unwrap();
2189 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
2190 assert_eq!(run.tests, 2);
2191 let runners = run
2192 .declaration
2193 .runners
2194 .iter()
2195 .map(|runner| runner.runner.clone())
2196 .collect::<Vec<_>>();
2197 assert_eq!(runners, ["minitest", "rspec"]);
2198 assert_eq!(run.ruby_versions, ["4.0.6"]);
2199 let background = run
2201 .request
2202 .raw_results
2203 .iter()
2204 .find(|raw| raw.role == "background")
2205 .unwrap();
2206 assert_eq!(background.provenance.kind, "background");
2207 fs::remove_dir_all(directory).unwrap();
2208 }
2209
2210 #[test]
2211 fn an_rspec_example_carries_its_tags_and_description_and_a_level_tag_is_its_kind() {
2212 let source = "def f(a)\n a ? 1 : 0\nend\n";
2213 let mut probe = 0;
2214 let obligations =
2215 build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
2216 let directory = temporary("tags");
2217 let outcome = |phase: &str| serde_json::json!({"t":"outcome","worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":phase,"outcome":"passed","xfail":false,"runner":"rspec","tags":["api","e2e"],"title":"M pays a member"});
2218 let records = [
2219 serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"3.4.10","executable":"ruby","argv":["rspec"]}),
2220 serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"call"}),
2221 outcome("setup"),
2222 outcome("call"),
2223 outcome("teardown"),
2224 serde_json::json!({"t":"exit","at":9}),
2225 ];
2226 fs::write(directory.join("main.7.a.mmap"), transport(&records)).unwrap();
2227 let run = build_ruby_frontend_run(
2228 &obligations.manifest,
2229 &directory,
2230 "run-1",
2231 "now",
2232 0,
2233 Path::new(""),
2234 )
2235 .unwrap();
2236 let test = run
2237 .request
2238 .raw_results
2239 .iter()
2240 .find(|raw| raw.role == "test")
2241 .unwrap();
2242 assert_eq!(test.provenance.tags, ["api", "e2e"]);
2243 assert_eq!(test.provenance.kind, "e2e");
2244 assert_eq!(test.title.as_deref(), Some("M pays a member"));
2245 fs::remove_dir_all(directory).unwrap();
2246 }
2247
2248 #[test]
2249 fn fails_closed_without_an_interpreter() {
2250 let mut probe = 0;
2251 let obligations = build_ruby_obligations("m.rb", b"x = 1\n", &mut probe).unwrap();
2252 let directory = temporary("empty");
2253 assert!(matches!(
2254 build_ruby_frontend_run(
2255 &obligations.manifest,
2256 &directory,
2257 "run-1",
2258 "now",
2259 0,
2260 Path::new("")
2261 ),
2262 Err(RubyEvidenceError::NoInterpreter)
2263 ));
2264 fs::remove_dir_all(directory).unwrap();
2265 }
2266}