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 span: None,
1410 test_id: Some(test.clone()),
1411 scope: Some(scope(run_id, &worker, &test, retry)),
1412 test: test.clone(),
1413 test_file: test_file.clone(),
1417 title: test_titles
1418 .get(&(worker.clone(), test.clone(), retry))
1419 .cloned()
1420 .or_else(|| test.rsplit("::").next().map(str::to_owned)),
1421 retry: Some(retry),
1422 status: Some(status),
1423 expected_status: Some(
1424 if outcomes.iter().any(|(_, _, xfail)| *xfail) {
1425 "failed"
1426 } else {
1427 "passed"
1428 }
1429 .into(),
1430 ),
1431 flaky: false,
1432 provenance: {
1433 let tags = test_tags
1434 .get(&(worker.clone(), test.clone(), retry))
1435 .cloned()
1436 .unwrap_or_default();
1437 TestProvenance {
1438 runner: runner.clone(),
1439 kind: crate::test_kind::with_tags(test_file.as_deref(), &tags).into(),
1440 project: None,
1441 source: RUBY_FRONTEND_VERSION.into(),
1442 tags,
1443 }
1444 },
1445 role: "test".into(),
1446 attribution: attribution.into(),
1452 phases,
1453 runtime,
1454 browser: Vec::new(),
1455 server: Vec::new(),
1456 });
1457 }
1458 let default_observed = observed_runners
1461 .iter()
1462 .next()
1463 .cloned()
1464 .unwrap_or_else(default_runner);
1465 for ((worker, test, retry), identities) in identities_by_attempt {
1466 let runner = default_observed.clone();
1467 let mut phases = Vec::new();
1468 let mut runtime = Vec::new();
1469 for (position, (identity, observations)) in identities.iter().enumerate() {
1470 let id = phase_id(run_id, identity);
1471 phases.push(CoveragePhase {
1472 id: id.clone(),
1473 kind: match identity.phase.as_str() {
1474 "call" => "test",
1475 value => value,
1476 }
1477 .into(),
1478 operation: format!("{runner} {}", identity.phase),
1479 source: Some(test.clone()),
1480 caused_by_phase_id: None,
1481 started_at_ms: position as i64 * 2 + 1,
1482 ended_at_ms: None,
1483 status: Some("failed".into()),
1484 error: Some("the phase started but the runner reported no outcome".into()),
1485 });
1486 runtime.push(snapshot(&index, observations, &id)?);
1487 }
1488 let test_file = test_files
1489 .get(&(worker.clone(), test.clone(), retry))
1490 .and_then(|path| relative(root, path))
1491 .or_else(|| test.split("::").next().map(str::to_owned));
1492 raw_results.push(RawTestResult {
1493 span: None,
1494 test_id: Some(test.clone()),
1495 scope: Some(scope(run_id, &worker, &test, retry)),
1496 test: test.clone(),
1497 test_file: test_file.clone(),
1501 title: test_titles
1502 .get(&(worker.clone(), test.clone(), retry))
1503 .cloned()
1504 .or_else(|| test.rsplit("::").next().map(str::to_owned)),
1505 retry: Some(retry),
1506 status: Some("failed".into()),
1507 expected_status: Some("passed".into()),
1508 flaky: false,
1509 provenance: {
1510 let tags = test_tags
1511 .get(&(worker.clone(), test.clone(), retry))
1512 .cloned()
1513 .unwrap_or_default();
1514 TestProvenance {
1515 runner: runner.clone(),
1516 kind: crate::test_kind::with_tags(test_file.as_deref(), &tags).into(),
1517 project: None,
1518 source: RUBY_FRONTEND_VERSION.into(),
1519 tags,
1520 }
1521 },
1522 role: "test".into(),
1523 attribution: attribution.into(),
1529 phases,
1530 runtime,
1531 browser: Vec::new(),
1532 server: Vec::new(),
1533 });
1534 }
1535 for (worker, observations) in &background {
1536 if observations.hits.is_empty() && observations.vectors.is_empty() {
1537 continue;
1538 }
1539 let test = format!("__supercov_background__:{worker}");
1540 let identity = Identity {
1541 worker: worker.clone(),
1542 test: test.clone(),
1543 retry: 0,
1544 phase: "background".into(),
1545 };
1546 let phase = phase_id(run_id, &identity);
1547 raw_results.push(RawTestResult {
1548 span: None,
1549 test_id: Some(test.clone()),
1550 scope: Some(scope(run_id, worker, &test, 0)),
1551 test: "Ruby load and background execution".into(),
1552 test_file: None,
1553 title: None,
1554 retry: Some(0),
1555 status: Some("unknown".into()),
1556 expected_status: None,
1557 flaky: false,
1558 provenance: TestProvenance {
1559 runner: default_observed.clone(),
1560 kind: "background".into(),
1561 project: None,
1562 source: RUBY_FRONTEND_VERSION.into(),
1563 tags: Vec::new(),
1564 },
1565 role: "background".into(),
1566 attribution: crate::coverage_report::ATTRIBUTION_EXACT.into(),
1567 phases: vec![CoveragePhase {
1568 id: phase.clone(),
1569 kind: "background".into(),
1570 operation: "Ruby load background".into(),
1571 source: None,
1572 caused_by_phase_id: None,
1573 started_at_ms: 0,
1574 ended_at_ms: Some(0),
1575 status: Some("passed".into()),
1576 error: None,
1577 }],
1578 runtime: vec![snapshot(&index, observations, &phase)?],
1579 browser: Vec::new(),
1580 server: Vec::new(),
1581 });
1582 }
1583
1584 let mut limitation_ids = manifest
1588 .limitations
1589 .iter()
1590 .filter_map(|item| item.get("id").and_then(serde_json::Value::as_str))
1591 .map(str::to_owned)
1592 .collect::<BTreeSet<_>>();
1593 let mut unmeasured = manifest.unmeasured.iter().cloned().collect::<BTreeSet<_>>();
1594 let mut new_limitations = Vec::new();
1595 for limitation in &limitations {
1596 if let Some(obligation) = &limitation.obligation {
1597 if !index.lines.contains_key(obligation.as_str()) {
1598 return Err(RubyEvidenceError::UnknownObligation(obligation.clone()));
1599 }
1600 unmeasured.insert(obligation.clone());
1601 } else if let Some(file) = &limitation.file {
1602 unmeasured.extend(
1607 index
1608 .lines
1609 .iter()
1610 .filter(|(_, (obligation_file, _))| obligation_file == file)
1611 .map(|(id, _)| (*id).to_owned()),
1612 );
1613 }
1614 if limitation_ids.insert(limitation.id.clone()) {
1615 let (file, line) = limitation
1616 .obligation
1617 .as_deref()
1618 .and_then(|id| index.lines.get(id).cloned())
1619 .unwrap_or_else(|| {
1620 (
1621 limitation.file.clone().unwrap_or_else(|| {
1622 manifest
1623 .points
1624 .first()
1625 .map_or(".".into(), |point| point.file.clone())
1626 }),
1627 1,
1628 )
1629 });
1630 let source = limitation
1631 .obligation
1632 .as_deref()
1633 .and_then(|id| index.sources.get(id))
1634 .map(|source| source.lines().next().unwrap_or_default().to_owned())
1635 .unwrap_or_default();
1636 new_limitations.push(json!({
1637 "id": limitation.id,
1638 "kind": "semantic-safety",
1639 "file": file,
1640 "line": line,
1641 "column": 0,
1642 "source": source,
1643 "reason": limitation.reason
1644 }));
1645 }
1646 }
1647 manifest.limitations.extend(new_limitations);
1648 manifest.unmeasured = unmeasured.into_iter().collect();
1649 let structural_limitations = limitation_ids.into_iter().collect::<Vec<_>>();
1650
1651 let tests = raw_results
1655 .iter()
1656 .filter(|raw| raw.role == "test")
1657 .map(|raw| raw.test.as_str())
1658 .collect::<BTreeSet<_>>()
1659 .len();
1660 Ok(RubyFrontendRun {
1661 declaration: FrontendRunDeclaration {
1662 protocol_version: LANGUAGE_FRONTEND_PROTOCOL_VERSION,
1663 frontend_id: "ruby".into(),
1664 frontend_version: RUBY_FRONTEND_VERSION.into(),
1665 language: "ruby".into(),
1666 structural_source: StructuralSource::OwnedProbes,
1667 runners: observed_runners
1668 .iter()
1669 .map(|runner| FrontendRunnerDeclaration {
1670 runner: runner.clone(),
1671 execution_model: ExecutionModel::SerialInProcess,
1672 attribution: FrontendAttribution {
1673 run: AttributionPrecision::Exact,
1674 worker: AttributionPrecision::Exact,
1675 test: AttributionPrecision::Exact,
1676 retry: AttributionPrecision::Exact,
1677 phase: AttributionPrecision::Exact,
1678 action: AttributionPrecision::Unavailable,
1679 assertion: if ruby_runner(runner) {
1682 AttributionPrecision::Exact
1683 } else {
1684 AttributionPrecision::Unavailable
1685 },
1686 },
1687 limitations: vec![if ruby_runner(runner) {
1688 FrontendLimitation {
1689 id: format!("ruby-{runner}-action-linkage"),
1690 scopes: vec![FrontendLimitationScope::Action],
1691 reason: format!("{runner} exposes no general action lifecycle"),
1692 }
1693 } else {
1694 FrontendLimitation {
1695 id: format!("ruby-server-{runner}-linkage"),
1696 scopes: vec![
1697 FrontendLimitationScope::Action,
1698 FrontendLimitationScope::Assertion,
1699 ],
1700 reason: format!(
1701 "the server sees each {runner} test's requests, not its actions or assertions"
1702 ),
1703 }
1704 }]
1705 .into_iter()
1706 .chain(first_sighting.then(|| FrontendLimitation {
1714 id: format!("ruby-{runner}-first-sighting-lines"),
1715 scopes: vec![FrontendLimitationScope::Test],
1716 reason:
1717 "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"
1718 .into(),
1719 }))
1720 .collect(),
1721 })
1722 .collect(),
1723 structural_limitations,
1724 },
1725 request: CoverageReportRequest {
1726 run_id: run_id.into(),
1727 manifest,
1728 raw_results,
1729 generated_at: generated_at.into(),
1730 coverage_model: Some(ruby_coverage_model()),
1731 integrity: None,
1732 test_exit_code: ExitCodeInput::Present(Some(test_exit_code)),
1733 },
1734 tests,
1735 interpreters,
1736 ruby_versions: ruby_versions.into_iter().collect(),
1737 })
1738}
1739
1740#[cfg(test)]
1741mod tests {
1742 use super::*;
1743 use crate::{
1744 coverage_analysis::PointKind, frontend_protocol::validate_frontend_report_request,
1745 ruby_instrumenter::build_ruby_obligations,
1746 };
1747
1748 fn frame(payload: &[u8]) -> Vec<u8> {
1749 let mut bytes = vec![1u8, 0, 0, 0];
1750 bytes.extend_from_slice(&(payload.len() as u32).to_le_bytes());
1751 bytes.extend_from_slice(&transport_checksum(payload).to_le_bytes());
1752 bytes.extend_from_slice(&[0, 0, 0, 0]);
1753 bytes.extend_from_slice(payload);
1754 while bytes.len() % 8 != 0 {
1755 bytes.push(0);
1756 }
1757 bytes
1758 }
1759
1760 fn transport(records: &[serde_json::Value]) -> Vec<u8> {
1761 let mut body = Vec::new();
1762 for record in records {
1763 body.extend(frame(record.to_string().as_bytes()));
1764 }
1765 let capacity = TRANSPORT_HEADER_SIZE + body.len();
1766 let mut bytes = vec![0u8; TRANSPORT_HEADER_SIZE];
1767 bytes[..8].copy_from_slice(TRANSPORT_MAGIC);
1768 bytes[8..12].copy_from_slice(&TRANSPORT_VERSION.to_le_bytes());
1769 bytes[12..16].copy_from_slice(&(TRANSPORT_HEADER_SIZE as u32).to_le_bytes());
1770 bytes[16..24].copy_from_slice(&(capacity as u64).to_le_bytes());
1771 bytes[32..40].copy_from_slice(&7u64.to_le_bytes());
1772 bytes.extend(body);
1773 bytes
1774 }
1775
1776 #[test]
1777 fn a_damaged_transport_is_refused_with_what_is_wrong() {
1778 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"]});
1781 let exit = serde_json::json!({"t":"exit","at":9});
1782 let valid = transport(&[process.clone(), exit.clone()]);
1783 let read = |bytes: &[u8]| match read_evidence_file(
1784 "main.7.a.mmap",
1785 bytes,
1786 "run-1",
1787 &mut Evidence::default(),
1788 ) {
1789 Ok(()) => "accepted".to_owned(),
1790 Err(error) => error.to_string(),
1791 };
1792 assert_eq!(read(&valid), "accepted");
1793 let payload_end = 80 + process.to_string().len();
1795 assert_ne!(payload_end % 8, 0, "the frame has padding to corrupt");
1796 let set = |at: usize, value: &[u8]| {
1797 let mut bytes = valid.clone();
1798 bytes[at..at + value.len()].copy_from_slice(value);
1799 bytes
1800 };
1801 let mut truncated = valid[..payload_end].to_vec();
1802 truncated[16..24].copy_from_slice(&(payload_end as u64).to_le_bytes());
1803 let other_owner = transport(&[
1804 serde_json::json!({"t":"process","v":1,"run":"run-1","pid":8,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["x.rb"]}),
1805 ]);
1806 let other_run = transport(&[
1807 serde_json::json!({"t":"process","v":1,"run":"run-2","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["x.rb"]}),
1808 ]);
1809 for (bytes, reason) in [
1810 (set(0, b"X"), "header or version does not match"),
1811 (
1812 set(16, &8_u64.to_le_bytes()),
1813 "declared capacity is outside the mapped file",
1814 ),
1815 (set(24, &3_u64.to_le_bytes()), "3"),
1816 (set(32, &0_u64.to_le_bytes()), "process id is missing"),
1817 (set(64, &[2]), "commit marker or reserved bytes are invalid"),
1818 (
1819 set(68, &0_u32.to_le_bytes()),
1820 "payload length is outside the transport bound",
1821 ),
1822 (
1823 set(68, &4096_u32.to_le_bytes()),
1824 "payload extends past the mapped file",
1825 ),
1826 (truncated, "aligned frame extends past the mapped file"),
1827 (set(payload_end, &[1]), "frame padding is not zero"),
1828 (
1829 set(72, &0_u32.to_le_bytes()),
1830 "payload checksum does not match",
1831 ),
1832 (other_owner, "does not match the transport owner"),
1833 (other_run, "run-2"),
1834 ] {
1835 let said = read(&bytes);
1836 assert!(said.contains(reason), "{reason}: {said}");
1837 }
1838 }
1839
1840 fn temporary(name: &str) -> std::path::PathBuf {
1841 let nonce = std::time::SystemTime::now()
1842 .duration_since(std::time::UNIX_EPOCH)
1843 .unwrap()
1844 .as_nanos();
1845 let path = std::env::temp_dir().join(format!(
1846 "supercov-ruby-evidence-{}-{nonce}-{name}",
1847 std::process::id()
1848 ));
1849 fs::create_dir_all(&path).unwrap();
1850 path
1851 }
1852
1853 #[test]
1854 fn declares_the_gap_when_a_process_never_reported() {
1855 let source = "def f(a)\n a\nend\n";
1862 let mut probe = 0;
1863 let obligations =
1864 build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
1865 let statement = obligations
1866 .manifest
1867 .points
1868 .iter()
1869 .find(|point| point.kind == PointKind::Statement)
1870 .unwrap();
1871 let base = [
1872 serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["child.rb"]}),
1873 serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
1874 serde_json::json!({"t":"hit","ctx":1,"id":statement.id}),
1875 serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":"passed","xfail":false,"runner":"minitest"}),
1876 ];
1877
1878 let killed = temporary("killed");
1879 fs::write(killed.join("main.7.a.mmap"), transport(&base)).unwrap();
1880 let run = build_ruby_frontend_run(
1881 &obligations.manifest,
1882 &killed,
1883 "run-1",
1884 "now",
1885 0,
1886 Path::new(""),
1887 )
1888 .unwrap();
1889 let declared = serde_json::to_string(&run.request.manifest.limitations).unwrap();
1890 assert!(
1891 declared.contains("ruby-process-did-not-report"),
1892 "a process that never reported must declare the gap: {declared}"
1893 );
1894 fs::remove_dir_all(&killed).unwrap();
1895
1896 let mut clean = base.to_vec();
1897 clean.push(serde_json::json!({"t":"exit","at":9}));
1898 let reported = temporary("reported");
1899 fs::write(reported.join("main.7.a.mmap"), transport(&clean)).unwrap();
1900 let run = build_ruby_frontend_run(
1901 &obligations.manifest,
1902 &reported,
1903 "run-1",
1904 "now",
1905 0,
1906 Path::new(""),
1907 )
1908 .unwrap();
1909 let declared = serde_json::to_string(&run.request.manifest.limitations).unwrap();
1910 assert!(
1911 !declared.contains("ruby-process-did-not-report"),
1912 "a process that reported cleanly must declare nothing: {declared}"
1913 );
1914 fs::remove_dir_all(&reported).unwrap();
1915 }
1916
1917 fn under(first: &str, rest: &str) -> String {
1922 let mut path = std::path::PathBuf::from(if cfg!(windows) {
1923 format!("C:\\{first}")
1924 } else {
1925 format!("/{first}")
1926 });
1927 for part in rest.split('/').filter(|part| !part.is_empty()) {
1928 path.push(part);
1929 }
1930 path.to_string_lossy().into_owned()
1931 }
1932
1933 fn assertion_sources(run: &RubyFrontendRun) -> Vec<String> {
1934 run.request.raw_results[0]
1935 .phases
1936 .iter()
1937 .filter(|phase| phase.kind == "assertion")
1938 .filter_map(|phase| phase.source.clone())
1939 .collect()
1940 }
1941
1942 fn run_with_sites(
1943 name: &str,
1944 sites: &[serde_json::Value],
1945 outcome_file: Option<&str>,
1946 root: &str,
1947 ) -> RubyFrontendRun {
1948 let source = "def f(a)\n a\nend\n";
1949 let mut probe = 0;
1950 let obligations =
1951 build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
1952 let mut outcome = serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":"passed","xfail":false,"runner":"minitest"});
1953 if let Some(file) = outcome_file {
1954 outcome["file"] = serde_json::json!(file);
1955 }
1956 let mut records = vec![
1957 serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["test.rb"]}),
1958 serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
1959 serde_json::json!({"t":"assert","ctx":1}),
1960 ];
1961 records.extend(sites.iter().cloned());
1962 records.push(outcome);
1963 records.push(serde_json::json!({"t":"exit","at":9}));
1964 let directory = temporary(name);
1965 fs::write(directory.join("main.7.a.mmap"), transport(&records)).unwrap();
1966 let run = build_ruby_frontend_run(
1967 &obligations.manifest,
1968 &directory,
1969 "run-1",
1970 "now",
1971 0,
1972 Path::new(root),
1973 )
1974 .unwrap();
1975 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
1976 fs::remove_dir_all(directory).unwrap();
1977 run
1978 }
1979
1980 #[test]
1981 fn a_test_s_assertions_are_its_one_assertion_phase() {
1982 let run = run_with_sites("ruby-assertion-phase", &[], None, &under("project", ""));
1985 assert_eq!(assertion_sources(&run), vec!["MTest#test_x".to_string()]);
1986 }
1987
1988 #[test]
1989 fn the_runner_names_the_test_file_in_either_path_form() {
1990 let root = under("project", "");
1994 for reported in [
1995 under("project", "test/m_test.rb"),
1996 "test/m_test.rb".to_owned(),
1997 "./test/m_test.rb".to_owned(),
1998 ] {
1999 let run = run_with_sites("asite-file", &[], Some(reported.as_str()), &root);
2000 assert_eq!(
2001 run.request.raw_results[0].test_file.as_deref(),
2002 Some("test/m_test.rb"),
2003 "{reported} should resolve to the project path"
2004 );
2005 }
2006 let without = run_with_sites("asite-nofile", &[], None, &root);
2007 assert_eq!(
2008 without.request.raw_results[0].test_file.as_deref(),
2009 Some("MTest#test_x"),
2010 "an adapter that cannot name a file keeps the identity fallback"
2011 );
2012 }
2013
2014 #[test]
2015 fn a_phase_a_runner_reported_more_than_once_is_one_phase() {
2016 let source = "def f(a)\n a\nend\n";
2020 let mut probe = 0;
2021 let obligations =
2022 build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
2023 let statement = obligations
2024 .manifest
2025 .points
2026 .iter()
2027 .find(|point| point.kind == PointKind::Statement)
2028 .unwrap();
2029 let again = |outcome: &str| {
2030 serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,
2031 "phase":"call","outcome":outcome,"xfail":false,"runner":"minitest"})
2032 };
2033 let records = [
2034 serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["test.rb"]}),
2035 serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
2036 serde_json::json!({"t":"hit","ctx":1,"id":statement.id}),
2037 again("passed"),
2038 again("failed"),
2039 again("passed"),
2040 serde_json::json!({"t":"exit","at":9}),
2041 ];
2042 let directory = temporary("repeated-phase");
2043 fs::write(directory.join("main.7.a.mmap"), transport(&records)).unwrap();
2044 let run = build_ruby_frontend_run(
2045 &obligations.manifest,
2046 &directory,
2047 "run-1",
2048 "now",
2049 1,
2050 Path::new(""),
2051 )
2052 .unwrap();
2053 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
2054 let test = &run.request.raw_results[0];
2055 assert_eq!(test.phases.len(), 1, "{:?}", test.phases);
2056 assert_eq!(test.status.as_deref(), Some("failed"));
2057 fs::remove_dir_all(directory).unwrap();
2058 }
2059
2060 #[test]
2061 fn evidence_before_the_first_assertion_links_to_it_when_the_test_passes() {
2062 let source = "def f(a)\n a\nend\n\ndef g(b)\n b\nend\n";
2067 let mut probe = 0;
2068 let obligations =
2069 build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
2070 let statements: Vec<_> = obligations
2071 .manifest
2072 .points
2073 .iter()
2074 .filter(|point| point.kind == PointKind::Statement)
2075 .collect();
2076 let (before, after) = (&statements[0].id, &statements[1].id);
2077 let run_with = |name: &str, outcome: &str| {
2078 let records = [
2079 serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["test.rb"]}),
2080 serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
2081 serde_json::json!({"t":"hit","ctx":1,"id":before}),
2082 serde_json::json!({"t":"assert","ctx":1}),
2083 serde_json::json!({"t":"assert","ctx":1}),
2084 serde_json::json!({"t":"hit","ctx":1,"id":after}),
2085 serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":outcome,"xfail":false,"runner":"minitest"}),
2086 serde_json::json!({"t":"exit","at":9}),
2087 ];
2088 let directory = temporary(name);
2089 fs::write(directory.join("main.7.a.mmap"), transport(&records)).unwrap();
2090 let run = build_ruby_frontend_run(
2091 &obligations.manifest,
2092 &directory,
2093 "run-1",
2094 "now",
2095 0,
2096 Path::new(""),
2097 )
2098 .unwrap();
2099 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
2100 fs::remove_dir_all(directory).unwrap();
2101 run
2102 };
2103 let events_of = |result: &RawTestResult, phase: &str| -> BTreeSet<String> {
2104 result
2105 .runtime
2106 .iter()
2107 .flat_map(|snapshot| snapshot.events.iter())
2108 .filter(|event| event.phase_id.as_deref() == Some(phase))
2109 .map(|event| event.id.clone())
2110 .collect()
2111 };
2112
2113 let run = run_with("asserted-passed", "passed");
2114 let passed = &run.request.raw_results[0];
2115 assert_eq!(passed.test, "MTest#test_x");
2116 let assertion = passed
2117 .phases
2118 .iter()
2119 .find(|phase| phase.kind == "assertion")
2120 .expect("the asserting test carries an assertion phase");
2121 assert_eq!(assertion.status.as_deref(), Some("passed"));
2122 assert_eq!(
2123 events_of(passed, &assertion.id),
2124 BTreeSet::from([before.clone()]),
2125 "only what ran before the marker is the assertion's evidence"
2126 );
2127 let test_phase = passed
2128 .phases
2129 .iter()
2130 .find(|phase| phase.kind == "test")
2131 .unwrap();
2132 assert_eq!(
2133 events_of(passed, &test_phase.id),
2134 BTreeSet::from([before.clone(), after.clone()]),
2135 "the test phase keeps everything it ran"
2136 );
2137 assert_eq!(
2138 run.declaration.runners[0].attribution.assertion,
2139 AttributionPrecision::Exact
2140 );
2141
2142 let run = run_with("asserted-failed", "failed");
2143 let failed = &run.request.raw_results[0];
2144 let assertion = failed
2145 .phases
2146 .iter()
2147 .find(|phase| phase.kind == "assertion")
2148 .unwrap();
2149 assert_eq!(
2150 assertion.status.as_deref(),
2151 Some("failed"),
2152 "a failed test's assertion witnessed nothing"
2153 );
2154 }
2155
2156 #[test]
2157 fn joins_rspec_and_minitest_outcomes_into_exact_results() {
2158 let source = "def f(a, b)\n if a && b\n 1\n else\n 0\n end\nend\n";
2159 let mut probe = 0;
2160 let obligations =
2161 build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
2162 let decision = &obligations.manifest.decisions[0];
2163 let statement = obligations
2164 .manifest
2165 .points
2166 .iter()
2167 .find(|point| point.kind == PointKind::Statement)
2168 .unwrap();
2169 let directory = temporary("join");
2170 let records = [
2171 serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["rspec"]}),
2172 serde_json::json!({"t":"hit","ctx":0,"id":statement.id}),
2173 serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"call"}),
2174 serde_json::json!({"t":"dec","ctx":1,"id":decision.id,"v":"22","o":1}),
2175 serde_json::json!({"t":"outcome","worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"setup","outcome":"passed","xfail":false,"runner":"rspec"}),
2176 serde_json::json!({"t":"outcome","worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"call","outcome":"passed","xfail":false,"runner":"rspec"}),
2177 serde_json::json!({"t":"outcome","worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"teardown","outcome":"passed","xfail":false,"runner":"rspec"}),
2178 serde_json::json!({"t":"phase","ctx":2,"at":6,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
2179 serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":"skipped","xfail":false,"runner":"minitest"}),
2180 serde_json::json!({"t":"exit","at":9}),
2181 ];
2182 fs::write(directory.join("main.7.a.mmap"), transport(&records)).unwrap();
2183 let run = build_ruby_frontend_run(
2184 &obligations.manifest,
2185 &directory,
2186 "run-1",
2187 "now",
2188 0,
2189 Path::new(""),
2190 )
2191 .unwrap();
2192 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
2193 assert_eq!(run.tests, 2);
2194 let runners = run
2195 .declaration
2196 .runners
2197 .iter()
2198 .map(|runner| runner.runner.clone())
2199 .collect::<Vec<_>>();
2200 assert_eq!(runners, ["minitest", "rspec"]);
2201 assert_eq!(run.ruby_versions, ["4.0.6"]);
2202 let background = run
2204 .request
2205 .raw_results
2206 .iter()
2207 .find(|raw| raw.role == "background")
2208 .unwrap();
2209 assert_eq!(background.provenance.kind, "background");
2210 fs::remove_dir_all(directory).unwrap();
2211 }
2212
2213 #[test]
2214 fn an_rspec_example_carries_its_tags_and_description_and_a_level_tag_is_its_kind() {
2215 let source = "def f(a)\n a ? 1 : 0\nend\n";
2216 let mut probe = 0;
2217 let obligations =
2218 build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
2219 let directory = temporary("tags");
2220 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"});
2221 let records = [
2222 serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"3.4.10","executable":"ruby","argv":["rspec"]}),
2223 serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"call"}),
2224 outcome("setup"),
2225 outcome("call"),
2226 outcome("teardown"),
2227 serde_json::json!({"t":"exit","at":9}),
2228 ];
2229 fs::write(directory.join("main.7.a.mmap"), transport(&records)).unwrap();
2230 let run = build_ruby_frontend_run(
2231 &obligations.manifest,
2232 &directory,
2233 "run-1",
2234 "now",
2235 0,
2236 Path::new(""),
2237 )
2238 .unwrap();
2239 let test = run
2240 .request
2241 .raw_results
2242 .iter()
2243 .find(|raw| raw.role == "test")
2244 .unwrap();
2245 assert_eq!(test.provenance.tags, ["api", "e2e"]);
2246 assert_eq!(test.provenance.kind, "e2e");
2247 assert_eq!(test.title.as_deref(), Some("M pays a member"));
2248 fs::remove_dir_all(directory).unwrap();
2249 }
2250
2251 #[test]
2252 fn fails_closed_without_an_interpreter() {
2253 let mut probe = 0;
2254 let obligations = build_ruby_obligations("m.rb", b"x = 1\n", &mut probe).unwrap();
2255 let directory = temporary("empty");
2256 assert!(matches!(
2257 build_ruby_frontend_run(
2258 &obligations.manifest,
2259 &directory,
2260 "run-1",
2261 "now",
2262 0,
2263 Path::new("")
2264 ),
2265 Err(RubyEvidenceError::NoInterpreter)
2266 ));
2267 fs::remove_dir_all(directory).unwrap();
2268 }
2269}