1use std::{
12 collections::{BTreeMap, BTreeSet},
13 fs::{self, File},
14 path::{Component, Path},
15};
16
17use memmap2::{Mmap, 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
54#[derive(Debug, Deserialize)]
57#[allow(dead_code)]
58#[serde(tag = "t", rename_all = "lowercase", deny_unknown_fields)]
59enum Record {
60 Process {
61 v: u32,
62 run: String,
63 pid: u64,
64 worker: String,
65 ruby: String,
66 executable: String,
67 argv: Vec<String>,
68 },
69 Worker {
70 worker: String,
71 },
72 Phase {
73 ctx: u64,
74 at: i64,
75 worker: String,
76 test: String,
77 retry: usize,
78 phase: String,
79 },
80 Outcome {
81 worker: String,
82 test: String,
83 retry: usize,
84 phase: String,
85 outcome: String,
86 xfail: bool,
87 #[serde(default = "default_runner")]
88 runner: String,
89 },
90 Hit {
91 ctx: u64,
92 id: String,
93 },
94 Dec {
95 ctx: u64,
96 id: String,
97 v: String,
98 o: u8,
99 },
100 Assert {
103 ctx: u64,
104 },
105 Limitation {
106 id: String,
107 reason: String,
108 #[serde(default)]
109 file: Option<String>,
110 #[serde(default)]
111 obligation: Option<String>,
112 },
113 Exit {
114 at: i64,
115 },
116}
117
118#[derive(Debug)]
119pub enum RubyEvidenceError {
120 Io(String),
121 UnsafeEntry(String),
122 InvalidRecord {
123 file: String,
124 line: usize,
125 reason: String,
126 },
127 InvalidTransport {
128 file: String,
129 reason: String,
130 },
131 DroppedRecords {
132 file: String,
133 count: u64,
134 },
135 RunMismatch {
136 expected: String,
137 actual: String,
138 },
139 UnsupportedVersion(u32),
140 UnknownContext {
141 file: String,
142 line: usize,
143 context: u64,
144 },
145 UnknownObligation(String),
146 InvalidVector {
147 id: String,
148 expected: usize,
149 actual: usize,
150 },
151 NoInterpreter,
152 NoTests,
153 UnsupportedRuby(String),
154}
155
156impl std::fmt::Display for RubyEvidenceError {
157 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158 match self {
159 Self::Io(reason) => write!(formatter, "could not read Ruby evidence: {reason}"),
160 Self::UnsafeEntry(name) => write!(formatter, "unsafe Ruby evidence entry: {name}"),
161 Self::InvalidRecord { file, line, reason } => {
162 write!(formatter, "invalid Ruby evidence record {file}:{line}: {reason}")
163 }
164 Self::InvalidTransport { file, reason } => {
165 write!(formatter, "invalid Ruby evidence transport {file}: {reason}")
166 }
167 Self::DroppedRecords { file, count } => write!(
168 formatter,
169 "Ruby evidence transport {file} exhausted its bounded capacity and dropped {count} record(s)"
170 ),
171 Self::RunMismatch { expected, actual } => write!(
172 formatter,
173 "Ruby evidence belongs to run {actual}, expected {expected}"
174 ),
175 Self::UnsupportedVersion(version) => {
176 write!(formatter, "unsupported Ruby evidence version {version}")
177 }
178 Self::UnknownContext { file, line, context } => write!(
179 formatter,
180 "Ruby evidence {file}:{line} references undeclared context {context}"
181 ),
182 Self::UnknownObligation(id) => {
183 write!(formatter, "Ruby runtime reported an unknown obligation: {id}")
184 }
185 Self::InvalidVector {
186 id,
187 expected,
188 actual,
189 } => write!(
190 formatter,
191 "Ruby decision {id} reported {actual} condition values, expected {expected}"
192 ),
193 Self::NoInterpreter => formatter.write_str(
194 "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)",
195 ),
196 Self::NoTests => formatter.write_str(
197 "the Ruby run produced no test outcomes; Supercov measures Ruby through RSpec, Minitest, test-unit and Cucumber",
198 ),
199 Self::UnsupportedRuby(version) => write!(
200 formatter,
201 "Supercov measures Ruby 3.3 or newer; the test command ran Ruby {version}"
202 ),
203 }
204 }
205}
206
207impl std::error::Error for RubyEvidenceError {}
208
209fn stable_id(prefix: &str, values: &[&str]) -> String {
210 let mut hash = Sha256::new();
211 for value in values {
212 hash.update(value.as_bytes());
213 hash.update([0]);
214 }
215 let digest = hash.finalize();
216 let mut encoded = String::with_capacity(prefix.len() + 25);
217 encoded.push_str(prefix);
218 encoded.push(':');
219 for byte in &digest[..12] {
220 use std::fmt::Write as _;
221 write!(&mut encoded, "{byte:02x}").expect("string formatting");
222 }
223 encoded
224}
225
226#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
227struct Identity {
228 worker: String,
229 test: String,
230 retry: usize,
231 phase: String,
232}
233
234type ObservedVectors = BTreeSet<(Vec<Option<bool>>, bool)>;
235type OutcomesByAttempt = BTreeMap<(String, String, usize), Vec<(String, String, bool)>>;
237type RunnersByAttempt = BTreeMap<(String, String, usize), String>;
239
240#[derive(Debug, Default)]
241struct Observations {
242 hits: BTreeSet<String>,
243 vectors: BTreeMap<String, ObservedVectors>,
244}
245
246#[derive(Debug, Clone)]
247struct RuntimeLimitation {
248 id: String,
249 reason: String,
250 file: Option<String>,
251 obligation: Option<String>,
252}
253
254#[derive(Debug, Default)]
255struct Evidence {
256 interpreters: usize,
257 ruby_versions: BTreeSet<String>,
258 per_identity: BTreeMap<Identity, Observations>,
259 background: BTreeMap<String, Observations>,
260 outcomes: OutcomesByAttempt,
261 runners: RunnersByAttempt,
262 limitations: Vec<RuntimeLimitation>,
263}
264
265fn read_evidence_directory(directory: &Path, run_id: &str) -> Result<Evidence, RubyEvidenceError> {
266 let mut evidence = Evidence::default();
267 let mut files = match fs::read_dir(directory) {
268 Ok(entries) => entries
269 .collect::<Result<Vec<_>, _>>()
270 .map_err(|error| RubyEvidenceError::Io(error.to_string()))?,
271 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
272 Err(error) => return Err(RubyEvidenceError::Io(error.to_string())),
273 };
274 files.sort_by_key(|entry| entry.file_name());
275 for entry in files {
276 let name = entry
277 .file_name()
278 .into_string()
279 .map_err(|_| RubyEvidenceError::UnsafeEntry("<non-utf8>".into()))?;
280 if Path::new(&name)
281 .components()
282 .any(|component| !matches!(component, Component::Normal(_)))
283 || !name.ends_with(".mmap")
284 {
285 return Err(RubyEvidenceError::UnsafeEntry(name));
286 }
287 let metadata = fs::symlink_metadata(entry.path())
288 .map_err(|error| RubyEvidenceError::Io(error.to_string()))?;
289 if !metadata.file_type().is_file() {
290 return Err(RubyEvidenceError::UnsafeEntry(name));
291 }
292 let file =
293 File::open(entry.path()).map_err(|error| RubyEvidenceError::Io(error.to_string()))?;
294 let contents = unsafe { MmapOptions::new().map(&file) }
298 .map_err(|error| RubyEvidenceError::Io(error.to_string()))?;
299 read_evidence_file(&name, &contents, run_id, &mut evidence)?;
300 }
301 Ok(evidence)
302}
303
304fn transport_u32(bytes: &[u8], offset: usize) -> Option<u32> {
305 bytes
306 .get(offset..offset + 4)
307 .and_then(|value| value.try_into().ok())
308 .map(u32::from_le_bytes)
309}
310
311fn transport_u64(bytes: &[u8], offset: usize) -> Option<u64> {
312 bytes
313 .get(offset..offset + 8)
314 .and_then(|value| value.try_into().ok())
315 .map(u64::from_le_bytes)
316}
317
318fn transport_checksum(payload: &[u8]) -> u32 {
319 payload.iter().fold(0x811c_9dc5_u32, |value, byte| {
320 (value ^ u32::from(*byte)).wrapping_mul(0x0100_0193)
321 })
322}
323
324fn align_transport(value: usize) -> Option<usize> {
325 value.checked_add(7).map(|value| value & !7)
326}
327
328fn read_evidence_file(
329 name: &str,
330 contents: &Mmap,
331 run_id: &str,
332 evidence: &mut Evidence,
333) -> Result<(), RubyEvidenceError> {
334 let invalid_transport = |reason: &str| RubyEvidenceError::InvalidTransport {
335 file: name.into(),
336 reason: reason.into(),
337 };
338 if contents.len() < TRANSPORT_HEADER_SIZE
339 || contents.get(..8) != Some(TRANSPORT_MAGIC.as_slice())
340 || transport_u32(contents, 8) != Some(TRANSPORT_VERSION)
341 || transport_u32(contents, 12) != Some(TRANSPORT_HEADER_SIZE as u32)
342 {
343 return Err(invalid_transport("header or version does not match"));
344 }
345 let declared_capacity =
346 transport_u64(contents, 16).ok_or_else(|| invalid_transport("capacity is missing"))?;
347 if declared_capacity < TRANSPORT_HEADER_SIZE as u64 || declared_capacity > contents.len() as u64
348 {
349 return Err(invalid_transport(
350 "declared capacity is outside the mapped file",
351 ));
352 }
353 let dropped =
354 transport_u64(contents, 24).ok_or_else(|| invalid_transport("drop counter is missing"))?;
355 if dropped != 0 {
356 return Err(RubyEvidenceError::DroppedRecords {
357 file: name.into(),
358 count: dropped,
359 });
360 }
361 let transport_pid = transport_u64(contents, 32)
362 .filter(|pid| *pid != 0)
363 .ok_or_else(|| invalid_transport("process id is missing"))?;
364 let mut contexts = BTreeMap::<u64, Identity>::new();
365 let mut before_assertion = BTreeMap::<u64, Observations>::new();
368 let mut process_worker: Option<String> = None;
369 let mut process_started = false;
370 let mut process_reported = false;
371 let mut cursor = TRANSPORT_HEADER_SIZE;
372 let mut record_index = 0;
373 while cursor + TRANSPORT_RECORD_HEADER_SIZE <= contents.len() {
374 let commit = contents[cursor];
375 if commit == 0 {
376 break;
379 }
380 record_index += 1;
381 let line_number = record_index;
382 let invalid = |reason: &str| RubyEvidenceError::InvalidRecord {
383 file: name.into(),
384 line: line_number,
385 reason: reason.into(),
386 };
387 if commit != 1
388 || contents[cursor + 1..cursor + 4] != [0, 0, 0]
389 || contents[cursor + 12..cursor + 16] != [0, 0, 0, 0]
390 {
391 return Err(invalid("commit marker or reserved bytes are invalid"));
392 }
393 let length = transport_u32(contents, cursor + 4)
394 .map(|value| value as usize)
395 .ok_or_else(|| invalid("payload length is missing"))?;
396 if length == 0 || length > TRANSPORT_MAX_RECORD_SIZE {
397 return Err(invalid("payload length is outside the transport bound"));
398 }
399 let payload_start = cursor + TRANSPORT_RECORD_HEADER_SIZE;
400 let payload_end = payload_start
401 .checked_add(length)
402 .filter(|end| *end <= contents.len())
403 .ok_or_else(|| invalid("payload extends past the mapped file"))?;
404 let next_cursor = align_transport(payload_end)
405 .filter(|end| *end <= contents.len())
406 .ok_or_else(|| invalid("aligned frame extends past the mapped file"))?;
407 if contents[payload_end..next_cursor]
408 .iter()
409 .any(|byte| *byte != 0)
410 {
411 return Err(invalid("frame padding is not zero"));
412 }
413 let payload = &contents[payload_start..payload_end];
414 let expected_checksum = transport_u32(contents, cursor + 8)
415 .ok_or_else(|| invalid("payload checksum is missing"))?;
416 if transport_checksum(payload) != expected_checksum {
417 return Err(invalid("payload checksum does not match"));
418 }
419 let record: Record =
420 serde_json::from_slice(payload).map_err(|error| RubyEvidenceError::InvalidRecord {
421 file: name.into(),
422 line: line_number,
423 reason: error.to_string(),
424 })?;
425 match record {
426 Record::Process {
427 v,
428 run,
429 pid,
430 worker,
431 ruby,
432 ..
433 } => {
434 if v != RUBY_EVIDENCE_VERSION {
435 return Err(RubyEvidenceError::UnsupportedVersion(v));
436 }
437 if run != run_id {
438 return Err(RubyEvidenceError::RunMismatch {
439 expected: run_id.into(),
440 actual: run,
441 });
442 }
443 if pid != transport_pid {
444 return Err(invalid("process record does not match the transport owner"));
445 }
446 let supported = ruby
447 .split('.')
448 .take(2)
449 .map(|part| part.parse::<u32>().ok())
450 .collect::<Option<Vec<_>>>()
451 .is_some_and(|parts| parts.len() == 2 && (parts[0], parts[1]) >= (3, 3));
452 if !supported {
453 return Err(RubyEvidenceError::UnsupportedRuby(ruby));
454 }
455 evidence.interpreters += 1;
456 evidence.ruby_versions.insert(ruby);
457 process_started = true;
458 process_worker = Some(worker);
459 }
460 Record::Worker { worker } => process_worker = Some(worker),
461 Record::Phase {
462 ctx,
463 worker,
464 test,
465 retry,
466 phase,
467 ..
468 } => {
469 if ctx == 0 {
470 return Err(invalid("phase context 0 is reserved for background"));
471 }
472 if !matches!(phase.as_str(), "setup" | "call" | "teardown") {
473 return Err(invalid("unknown test phase"));
474 }
475 if test.trim().is_empty() || worker.trim().is_empty() {
476 return Err(invalid("phase identity must name a worker and test"));
477 }
478 if phase == "call" {
479 before_assertion.insert(ctx, Observations::default());
480 }
481 contexts.insert(
482 ctx,
483 Identity {
484 worker,
485 test,
486 retry,
487 phase,
488 },
489 );
490 }
491 Record::Outcome {
492 worker,
493 test,
494 retry,
495 phase,
496 outcome,
497 xfail,
498 runner,
499 } => {
500 if !matches!(phase.as_str(), "setup" | "call" | "teardown") {
501 return Err(invalid("unknown test outcome phase"));
502 }
503 if !matches!(
504 outcome.as_str(),
505 "passed" | "failed" | "skipped" | "rerun" | "error"
506 ) {
507 return Err(invalid("unknown test outcome"));
508 }
509 if !matches!(
510 runner.as_str(),
511 RSPEC_RUNNER | MINITEST_RUNNER | TEST_UNIT_RUNNER | CUCUMBER_RUNNER
512 ) {
513 return Err(invalid("unknown Ruby test runner"));
514 }
515 let key = (worker, test, retry);
516 if let Some(previous) = evidence.runners.get(&key)
517 && previous != &runner
518 {
519 return Err(invalid("one attempt was reported by two runners"));
520 }
521 evidence.runners.insert(key.clone(), runner);
522 evidence
523 .outcomes
524 .entry(key)
525 .or_default()
526 .push((phase, outcome, xfail));
527 }
528 Record::Hit { ctx, id } => {
529 if let Some(before) = before_assertion.get_mut(&ctx) {
530 before.hits.insert(id.clone());
531 }
532 observations(
533 evidence,
534 &contexts,
535 process_worker.as_deref(),
536 ctx,
537 name,
538 line_number,
539 )?
540 .hits
541 .insert(id);
542 }
543 Record::Dec { ctx, id, v, o } => {
544 if v.is_empty() || !v.bytes().all(|digit| matches!(digit, b'0' | b'1' | b'2')) {
545 return Err(invalid("decision vector digits must be 0, 1 or 2"));
546 }
547 if o > 1 {
548 return Err(invalid("decision outcome must be 0 or 1"));
549 }
550 let values = v
551 .bytes()
552 .map(|digit| match digit {
553 b'0' => None,
554 b'1' => Some(false),
555 _ => Some(true),
556 })
557 .collect::<Vec<_>>();
558 if let Some(before) = before_assertion.get_mut(&ctx) {
559 before
560 .vectors
561 .entry(id.clone())
562 .or_default()
563 .insert((values.clone(), o == 1));
564 }
565 observations(
566 evidence,
567 &contexts,
568 process_worker.as_deref(),
569 ctx,
570 name,
571 line_number,
572 )?
573 .vectors
574 .entry(id)
575 .or_default()
576 .insert((values, o == 1));
577 }
578 Record::Assert { ctx } => {
579 if let Some(before) = before_assertion.remove(&ctx) {
582 let identity = contexts
583 .get(&ctx)
584 .ok_or(RubyEvidenceError::UnknownContext {
585 file: name.into(),
586 line: line_number,
587 context: ctx,
588 })?;
589 let asserted = evidence
590 .per_identity
591 .entry(Identity {
592 phase: "assertion".into(),
593 ..identity.clone()
594 })
595 .or_default();
596 asserted.hits.extend(before.hits);
597 for (id, vectors) in before.vectors {
598 asserted.vectors.entry(id).or_default().extend(vectors);
599 }
600 }
601 }
602 Record::Limitation {
603 id,
604 reason,
605 file,
606 obligation,
607 } => evidence.limitations.push(RuntimeLimitation {
608 id,
609 reason,
610 file,
611 obligation,
612 }),
613 Record::Exit { .. } => process_reported = true,
614 }
615 cursor = next_cursor;
616 }
617 if process_started && !process_reported {
626 evidence.limitations.push(RuntimeLimitation {
627 id: "ruby-process-did-not-report".into(),
628 reason: format!(
629 "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"
630 ),
631 file: None,
632 obligation: None,
633 });
634 }
635 Ok(())
636}
637
638fn observations<'a>(
639 evidence: &'a mut Evidence,
640 contexts: &BTreeMap<u64, Identity>,
641 process_worker: Option<&str>,
642 context: u64,
643 file: &str,
644 line: usize,
645) -> Result<&'a mut Observations, RubyEvidenceError> {
646 if context == 0 {
647 return Ok(evidence
648 .background
649 .entry(process_worker.unwrap_or("main").to_owned())
650 .or_default());
651 }
652 let identity = contexts
653 .get(&context)
654 .ok_or(RubyEvidenceError::UnknownContext {
655 file: file.into(),
656 line,
657 context,
658 })?;
659 Ok(evidence.per_identity.entry(identity.clone()).or_default())
660}
661
662pub fn ruby_coverage_model() -> CoverageModelDeclaration {
663 CoverageModelDeclaration {
664 language: "ruby".into(),
665 variant: "ruby-owned-coverage".into(),
666 name: "ruby-coverage-probes-v1".into(),
667 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(),
668 measured: vec![
669 "executable statements proven by Ruby's line coverage, or a probe when a line holds several".into(),
670 "method definitions entered (Ruby's method coverage)".into(),
671 "if/unless/elsif/ternary/while/until decisions with masking MC/DC vectors from operand probes".into(),
672 "while, until, for and iterator-block (each, map, times, ...) zero-versus-entered iteration".into(),
673 "&&, ||, ||= and &&= short-circuit alternatives".into(),
674 "case/when and case/in clause selection, safe navigation".into(),
675 "begin/rescue completion, handler selection and exception propagation".into(),
676 "RSpec, Minitest, test-unit and Cucumber worker, test and setup/call/teardown phase identity".into(),
677 "evidence a test recorded before its first assertion, linked to that assertion when the test passes".into(),
678 ],
679 not_measured: vec![
680 "blocks and lambdas as function entry points (they are statements inside their methods)".into(),
681 "blocks passed by reference (map(&:to_s)) as loops: they have no block body to observe".into(),
682 "line, branch and method observations made while test phases overlapped in threads (attributed to the run; probe observations stay per test)".into(),
683 "causal linkage to individual actions, or to any assertion after a test's first".into(),
684 "code compiled from strings at runtime (eval, instance_eval with strings)".into(),
685 "child coverage outside Process.spawn, Kernel#spawn, Kernel#system and fork".into(),
686 "all input values, semantic partitions, paths, or concurrency interleavings".into(),
687 "mutation score or assertion fault-detection strength".into(),
688 ],
689 }
690}
691
692fn phase_id(run: &str, identity: &Identity) -> String {
693 stable_id(
694 "ruby-phase",
695 &[
696 run,
697 &identity.worker,
698 &identity.test,
699 &identity.retry.to_string(),
700 &identity.phase,
701 ],
702 )
703}
704
705fn scope(run: &str, worker: &str, test: &str, retry: usize) -> ExecutionScope {
706 ExecutionScope {
707 version: 1,
708 run_id: run.into(),
709 worker_id: worker.into(),
710 test_id: test.into(),
711 test_key: stable_id("ruby-test", &[worker, test]),
712 retry,
713 attempt_id: stable_id("ruby-attempt", &[run, worker, test, &retry.to_string()]),
714 }
715}
716
717struct ManifestIndex<'a> {
718 points: BTreeSet<&'a str>,
719 alternatives: BTreeSet<&'a str>,
720 decisions: BTreeMap<&'a str, &'a DecisionMeta>,
721 lines: BTreeMap<&'a str, (String, usize)>,
722 sources: BTreeMap<&'a str, &'a str>,
723}
724
725impl<'a> ManifestIndex<'a> {
726 fn new(manifest: &'a CoverageManifest) -> Self {
727 let mut lines = BTreeMap::new();
728 let mut sources = BTreeMap::new();
729 for point in &manifest.points {
730 lines.insert(point.id.as_str(), (point.file.clone(), point.line));
731 sources.insert(point.id.as_str(), point.source.as_str());
732 }
733 for decision in &manifest.decisions {
734 lines.insert(decision.id.as_str(), (decision.file.clone(), decision.line));
735 sources.insert(decision.id.as_str(), decision.source.as_str());
736 }
737 for branch in &manifest.branches {
738 lines.insert(branch.id.as_str(), (branch.file.clone(), branch.line));
739 sources.insert(branch.id.as_str(), branch.source.as_str());
740 }
741 Self {
742 points: manifest
743 .points
744 .iter()
745 .map(|point| point.id.as_str())
746 .collect(),
747 alternatives: manifest
748 .branches
749 .iter()
750 .flat_map(|branch| branch.alternatives.iter().map(|alt| alt.id.as_str()))
751 .collect(),
752 decisions: manifest
753 .decisions
754 .iter()
755 .map(|decision| (decision.id.as_str(), decision))
756 .collect(),
757 lines,
758 sources,
759 }
760 }
761}
762
763fn snapshot(
764 index: &ManifestIndex<'_>,
765 observations: &Observations,
766 phase: &str,
767) -> Result<RuntimeSnapshot, RubyEvidenceError> {
768 let mut hits = BTreeSet::new();
769 for id in &observations.hits {
770 if !index.points.contains(id.as_str()) && !index.alternatives.contains(id.as_str()) {
771 return Err(RubyEvidenceError::UnknownObligation(id.clone()));
772 }
773 hits.insert(id.clone());
774 }
775 let mut decisions = Vec::new();
776 let mut events = Vec::new();
777 let mut clock = 1;
778 for id in &hits {
779 events.push(RuntimeEvent {
780 event_type: "hit".into(),
781 id: id.clone(),
782 vector: None,
783 timestamp_ms: clock,
784 phase_id: Some(phase.into()),
785 statement_id: None,
786 environment: "ruby".into(),
787 });
788 clock += 1;
789 }
790 for (id, vectors) in &observations.vectors {
791 let Some(meta) = index.decisions.get(id.as_str()) else {
792 return Err(RubyEvidenceError::UnknownObligation(id.clone()));
793 };
794 let mut observed = Vec::new();
795 for (values, outcome) in vectors {
796 if values.len() != meta.conditions.len() {
797 return Err(RubyEvidenceError::InvalidVector {
798 id: id.clone(),
799 expected: meta.conditions.len(),
800 actual: values.len(),
801 });
802 }
803 let vector = McdcVector {
804 values: values.clone(),
805 outcome: *outcome,
806 };
807 events.push(RuntimeEvent {
808 event_type: "decision".into(),
809 id: id.clone(),
810 vector: Some(vector.clone()),
811 timestamp_ms: clock,
812 phase_id: Some(phase.into()),
813 statement_id: None,
814 environment: "ruby".into(),
815 });
816 clock += 1;
817 observed.push(vector);
818 }
819 decisions.push(DecisionSnapshot {
820 meta: (*meta).clone(),
821 vectors: observed,
822 });
823 }
824 Ok(RuntimeSnapshot {
825 decisions,
826 hits: hits.into_iter().collect(),
827 events,
828 logicals: Vec::new(),
829 })
830}
831
832fn attempt_status(outcomes: &[(String, String, bool)]) -> String {
833 if outcomes
834 .iter()
835 .any(|(_, outcome, _)| matches!(outcome.as_str(), "failed" | "rerun" | "error"))
836 {
837 "failed"
838 } else if outcomes.iter().any(|(_, outcome, _)| outcome == "skipped") {
839 "skipped"
840 } else {
841 "passed"
842 }
843 .into()
844}
845
846#[derive(Debug, Clone, PartialEq)]
847pub struct RubyFrontendRun {
848 pub declaration: FrontendRunDeclaration,
849 pub request: CoverageReportRequest,
850 pub tests: usize,
851 pub interpreters: usize,
852 pub ruby_versions: Vec<String>,
853}
854
855impl RubyFrontendRun {
856 pub fn archive_entries(&self) -> Result<Vec<EvidenceArchiveEntry>, serde_json::Error> {
857 let model = PersistedCoverageModel::from_declaration(
858 self.request
859 .coverage_model
860 .as_ref()
861 .expect("Ruby frontend always declares a coverage model"),
862 )
863 .expect("Ruby coverage model is contract-valid");
864 let mut entries = vec![
865 EvidenceArchiveEntry {
866 path: "coverage-model.json".into(),
867 contents: serde_json::to_vec(&model)?,
868 },
869 EvidenceArchiveEntry {
870 path: "frontend.json".into(),
871 contents: serde_json::to_vec(&self.declaration)?,
872 },
873 EvidenceArchiveEntry {
874 path: "manifest.json".into(),
875 contents: serde_json::to_vec(&self.request.manifest)?,
876 },
877 ];
878 for (index, result) in self.request.raw_results.iter().enumerate() {
879 entries.push(EvidenceArchiveEntry {
880 path: format!("results/{index:08}/mcdc.json"),
881 contents: serde_json::to_vec(result)?,
882 });
883 }
884 Ok(entries)
885 }
886}
887
888pub fn build_ruby_frontend_run(
891 manifest: &CoverageManifest,
892 evidence_directory: &Path,
893 run_id: &str,
894 generated_at: &str,
895 test_exit_code: i32,
896) -> Result<RubyFrontendRun, RubyEvidenceError> {
897 let evidence = read_evidence_directory(evidence_directory, run_id)?;
898 if evidence.interpreters == 0 {
899 return Err(RubyEvidenceError::NoInterpreter);
900 }
901 if evidence.outcomes.is_empty() {
902 return Err(RubyEvidenceError::NoTests);
903 }
904 let Evidence {
905 interpreters,
906 ruby_versions,
907 per_identity,
908 background,
909 outcomes,
910 runners,
911 limitations,
912 } = evidence;
913 let mut manifest = manifest.clone();
914 let index = ManifestIndex::new(&manifest);
915
916 let mut raw_results = Vec::new();
917 let mut observed_runners = BTreeSet::new();
918 let mut identities_by_attempt =
919 BTreeMap::<(String, String, usize), Vec<(&Identity, &Observations)>>::new();
920 for (identity, observations) in &per_identity {
921 identities_by_attempt
922 .entry((
923 identity.worker.clone(),
924 identity.test.clone(),
925 identity.retry,
926 ))
927 .or_default()
928 .push((identity, observations));
929 }
930 for ((worker, test, retry), mut outcomes) in outcomes {
931 let runner = runners
932 .get(&(worker.clone(), test.clone(), retry))
933 .cloned()
934 .unwrap_or_else(default_runner);
935 let attempt_identities = identities_by_attempt
936 .remove(&(worker.clone(), test.clone(), retry))
937 .unwrap_or_default();
938 observed_runners.insert(runner.clone());
939 outcomes.sort_by_key(|(phase, _, _)| match phase.as_str() {
940 "setup" => 0,
941 "call" => 1,
942 _ => 2,
943 });
944 let mut phases = Vec::new();
945 let mut runtime = Vec::new();
946 let mut observed_phases = BTreeSet::new();
947 for (position, (phase_name, outcome, xfail)) in outcomes.iter().enumerate() {
948 observed_phases.insert(phase_name.clone());
949 let identity = Identity {
950 worker: worker.clone(),
951 test: test.clone(),
952 retry,
953 phase: phase_name.clone(),
954 };
955 let id = phase_id(run_id, &identity);
956 phases.push(CoveragePhase {
957 id: id.clone(),
958 kind: match phase_name.as_str() {
959 "call" => "test",
960 value => value,
961 }
962 .into(),
963 operation: format!("{runner} {phase_name}"),
964 source: Some(test.clone()),
965 caused_by_phase_id: None,
966 started_at_ms: position as i64 * 2 + 1,
967 ended_at_ms: Some(position as i64 * 2 + 2),
968 status: Some(match outcome.as_str() {
969 "rerun" | "error" => "failed".into(),
970 value => value.into(),
971 }),
972 error: None,
973 });
974 if let Some((_, observations)) = attempt_identities
975 .iter()
976 .find(|(candidate, _)| candidate.phase == phase_name.as_str())
977 {
978 runtime.push(snapshot(&index, observations, &id)?);
979 }
980 if phase_name != "call" {
981 continue;
982 }
983 if let Some((identity, observations)) = attempt_identities
987 .iter()
988 .find(|(candidate, _)| candidate.phase == "assertion")
989 {
990 observed_phases.insert("assertion".to_owned());
991 let id = phase_id(run_id, identity);
992 phases.push(CoveragePhase {
993 id: id.clone(),
994 kind: "assertion".into(),
995 operation: format!("{runner} assertion"),
996 source: Some(test.clone()),
997 caused_by_phase_id: None,
998 started_at_ms: position as i64 * 2 + 1,
999 ended_at_ms: Some(position as i64 * 2 + 2),
1000 status: Some(
1001 if outcome == "passed" && !*xfail {
1002 "passed"
1003 } else {
1004 "failed"
1005 }
1006 .into(),
1007 ),
1008 error: None,
1009 });
1010 runtime.push(snapshot(&index, observations, &id)?);
1011 }
1012 }
1013 for (identity, observations) in attempt_identities {
1016 if !observed_phases.contains(&identity.phase) {
1017 let id = phase_id(run_id, identity);
1018 phases.push(CoveragePhase {
1019 id: id.clone(),
1020 kind: match identity.phase.as_str() {
1021 "call" => "test",
1022 value => value,
1023 }
1024 .into(),
1025 operation: format!("{runner} {}", identity.phase),
1026 source: Some(test.clone()),
1027 caused_by_phase_id: None,
1028 started_at_ms: phases.len() as i64 * 2 + 1,
1029 ended_at_ms: None,
1030 status: Some("failed".into()),
1031 error: Some("the phase started but the runner reported no outcome".into()),
1032 });
1033 runtime.push(snapshot(&index, observations, &id)?);
1034 }
1035 }
1036 let status = if phases.iter().any(|phase| phase.error.is_some()) {
1037 "failed".into()
1038 } else {
1039 attempt_status(&outcomes)
1040 };
1041 raw_results.push(RawTestResult {
1042 test_id: Some(test.clone()),
1043 scope: Some(scope(run_id, &worker, &test, retry)),
1044 test: test.clone(),
1045 test_file: test.split("::").next().map(str::to_owned),
1046 title: test.rsplit("::").next().map(str::to_owned),
1047 retry: Some(retry),
1048 status: Some(status),
1049 expected_status: Some(
1050 if outcomes.iter().any(|(_, _, xfail)| *xfail) {
1051 "failed"
1052 } else {
1053 "passed"
1054 }
1055 .into(),
1056 ),
1057 flaky: false,
1058 provenance: TestProvenance {
1059 runner: runner.clone(),
1060 kind: "unit".into(),
1061 project: None,
1062 source: RUBY_FRONTEND_VERSION.into(),
1063 },
1064 role: "test".into(),
1065 phases,
1066 runtime,
1067 browser: Vec::new(),
1068 server: Vec::new(),
1069 });
1070 }
1071 let default_observed = observed_runners
1074 .iter()
1075 .next()
1076 .cloned()
1077 .unwrap_or_else(default_runner);
1078 for ((worker, test, retry), identities) in identities_by_attempt {
1079 let runner = default_observed.clone();
1080 let mut phases = Vec::new();
1081 let mut runtime = Vec::new();
1082 for (position, (identity, observations)) in identities.iter().enumerate() {
1083 let id = phase_id(run_id, identity);
1084 phases.push(CoveragePhase {
1085 id: id.clone(),
1086 kind: match identity.phase.as_str() {
1087 "call" => "test",
1088 value => value,
1089 }
1090 .into(),
1091 operation: format!("{runner} {}", identity.phase),
1092 source: Some(test.clone()),
1093 caused_by_phase_id: None,
1094 started_at_ms: position as i64 * 2 + 1,
1095 ended_at_ms: None,
1096 status: Some("failed".into()),
1097 error: Some("the phase started but the runner reported no outcome".into()),
1098 });
1099 runtime.push(snapshot(&index, observations, &id)?);
1100 }
1101 raw_results.push(RawTestResult {
1102 test_id: Some(test.clone()),
1103 scope: Some(scope(run_id, &worker, &test, retry)),
1104 test: test.clone(),
1105 test_file: test.split("::").next().map(str::to_owned),
1106 title: test.rsplit("::").next().map(str::to_owned),
1107 retry: Some(retry),
1108 status: Some("failed".into()),
1109 expected_status: Some("passed".into()),
1110 flaky: false,
1111 provenance: TestProvenance {
1112 runner: runner.clone(),
1113 kind: "unit".into(),
1114 project: None,
1115 source: RUBY_FRONTEND_VERSION.into(),
1116 },
1117 role: "test".into(),
1118 phases,
1119 runtime,
1120 browser: Vec::new(),
1121 server: Vec::new(),
1122 });
1123 }
1124 for (worker, observations) in &background {
1125 if observations.hits.is_empty() && observations.vectors.is_empty() {
1126 continue;
1127 }
1128 let test = format!("__supercov_background__:{worker}");
1129 let identity = Identity {
1130 worker: worker.clone(),
1131 test: test.clone(),
1132 retry: 0,
1133 phase: "background".into(),
1134 };
1135 let phase = phase_id(run_id, &identity);
1136 raw_results.push(RawTestResult {
1137 test_id: Some(test.clone()),
1138 scope: Some(scope(run_id, worker, &test, 0)),
1139 test: "Ruby load and background execution".into(),
1140 test_file: None,
1141 title: None,
1142 retry: Some(0),
1143 status: Some("unknown".into()),
1144 expected_status: None,
1145 flaky: false,
1146 provenance: TestProvenance {
1147 runner: default_observed.clone(),
1148 kind: "unit".into(),
1149 project: None,
1150 source: RUBY_FRONTEND_VERSION.into(),
1151 },
1152 role: "background".into(),
1153 phases: vec![CoveragePhase {
1154 id: phase.clone(),
1155 kind: "background".into(),
1156 operation: "Ruby load background".into(),
1157 source: None,
1158 caused_by_phase_id: None,
1159 started_at_ms: 0,
1160 ended_at_ms: Some(0),
1161 status: Some("passed".into()),
1162 error: None,
1163 }],
1164 runtime: vec![snapshot(&index, observations, &phase)?],
1165 browser: Vec::new(),
1166 server: Vec::new(),
1167 });
1168 }
1169
1170 let mut limitation_ids = manifest
1174 .limitations
1175 .iter()
1176 .filter_map(|item| item.get("id").and_then(serde_json::Value::as_str))
1177 .map(str::to_owned)
1178 .collect::<BTreeSet<_>>();
1179 let mut unmeasured = manifest.unmeasured.iter().cloned().collect::<BTreeSet<_>>();
1180 let mut new_limitations = Vec::new();
1181 for limitation in &limitations {
1182 if let Some(obligation) = &limitation.obligation {
1183 if !index.lines.contains_key(obligation.as_str()) {
1184 return Err(RubyEvidenceError::UnknownObligation(obligation.clone()));
1185 }
1186 unmeasured.insert(obligation.clone());
1187 } else if let Some(file) = &limitation.file {
1188 unmeasured.extend(
1193 index
1194 .lines
1195 .iter()
1196 .filter(|(_, (obligation_file, _))| obligation_file == file)
1197 .map(|(id, _)| (*id).to_owned()),
1198 );
1199 }
1200 if limitation_ids.insert(limitation.id.clone()) {
1201 let (file, line) = limitation
1202 .obligation
1203 .as_deref()
1204 .and_then(|id| index.lines.get(id).cloned())
1205 .unwrap_or_else(|| {
1206 (
1207 limitation.file.clone().unwrap_or_else(|| {
1208 manifest
1209 .points
1210 .first()
1211 .map_or(".".into(), |point| point.file.clone())
1212 }),
1213 1,
1214 )
1215 });
1216 let source = limitation
1217 .obligation
1218 .as_deref()
1219 .and_then(|id| index.sources.get(id))
1220 .map(|source| source.lines().next().unwrap_or_default().to_owned())
1221 .unwrap_or_default();
1222 new_limitations.push(json!({
1223 "id": limitation.id,
1224 "kind": "semantic-safety",
1225 "file": file,
1226 "line": line,
1227 "column": 0,
1228 "source": source,
1229 "reason": limitation.reason
1230 }));
1231 }
1232 }
1233 manifest.limitations.extend(new_limitations);
1234 manifest.unmeasured = unmeasured.into_iter().collect();
1235 let structural_limitations = limitation_ids.into_iter().collect::<Vec<_>>();
1236
1237 let tests = raw_results
1241 .iter()
1242 .filter(|raw| raw.role == "test")
1243 .map(|raw| raw.test.as_str())
1244 .collect::<BTreeSet<_>>()
1245 .len();
1246 Ok(RubyFrontendRun {
1247 declaration: FrontendRunDeclaration {
1248 protocol_version: LANGUAGE_FRONTEND_PROTOCOL_VERSION,
1249 frontend_id: "ruby".into(),
1250 frontend_version: RUBY_FRONTEND_VERSION.into(),
1251 language: "ruby".into(),
1252 structural_source: StructuralSource::OwnedProbes,
1253 runners: observed_runners
1254 .iter()
1255 .map(|runner| FrontendRunnerDeclaration {
1256 runner: runner.clone(),
1257 execution_model: ExecutionModel::SerialInProcess,
1258 attribution: FrontendAttribution {
1259 run: AttributionPrecision::Exact,
1260 worker: AttributionPrecision::Exact,
1261 test: AttributionPrecision::Exact,
1262 retry: AttributionPrecision::Exact,
1263 phase: AttributionPrecision::Exact,
1264 action: AttributionPrecision::Unavailable,
1265 assertion: AttributionPrecision::Exact,
1266 },
1267 limitations: vec![FrontendLimitation {
1268 id: format!("ruby-{runner}-action-linkage"),
1269 scopes: vec![FrontendLimitationScope::Action],
1270 reason: format!("{runner} exposes no general action lifecycle"),
1271 }],
1272 })
1273 .collect(),
1274 structural_limitations,
1275 },
1276 request: CoverageReportRequest {
1277 run_id: run_id.into(),
1278 manifest,
1279 raw_results,
1280 generated_at: generated_at.into(),
1281 coverage_model: Some(ruby_coverage_model()),
1282 integrity: None,
1283 test_exit_code: ExitCodeInput::Present(Some(test_exit_code)),
1284 },
1285 tests,
1286 interpreters,
1287 ruby_versions: ruby_versions.into_iter().collect(),
1288 })
1289}
1290
1291#[cfg(test)]
1292mod tests {
1293 use super::*;
1294 use crate::{
1295 coverage_analysis::PointKind, frontend_protocol::validate_frontend_report_request,
1296 ruby_instrumenter::build_ruby_obligations,
1297 };
1298
1299 fn frame(payload: &[u8]) -> Vec<u8> {
1300 let mut bytes = vec![1u8, 0, 0, 0];
1301 bytes.extend_from_slice(&(payload.len() as u32).to_le_bytes());
1302 bytes.extend_from_slice(&transport_checksum(payload).to_le_bytes());
1303 bytes.extend_from_slice(&[0, 0, 0, 0]);
1304 bytes.extend_from_slice(payload);
1305 while bytes.len() % 8 != 0 {
1306 bytes.push(0);
1307 }
1308 bytes
1309 }
1310
1311 fn transport(records: &[serde_json::Value]) -> Vec<u8> {
1312 let mut body = Vec::new();
1313 for record in records {
1314 body.extend(frame(record.to_string().as_bytes()));
1315 }
1316 let capacity = TRANSPORT_HEADER_SIZE + body.len();
1317 let mut bytes = vec![0u8; TRANSPORT_HEADER_SIZE];
1318 bytes[..8].copy_from_slice(TRANSPORT_MAGIC);
1319 bytes[8..12].copy_from_slice(&TRANSPORT_VERSION.to_le_bytes());
1320 bytes[12..16].copy_from_slice(&(TRANSPORT_HEADER_SIZE as u32).to_le_bytes());
1321 bytes[16..24].copy_from_slice(&(capacity as u64).to_le_bytes());
1322 bytes[32..40].copy_from_slice(&7u64.to_le_bytes());
1323 bytes.extend(body);
1324 bytes
1325 }
1326
1327 fn temporary(name: &str) -> std::path::PathBuf {
1328 let nonce = std::time::SystemTime::now()
1329 .duration_since(std::time::UNIX_EPOCH)
1330 .unwrap()
1331 .as_nanos();
1332 let path = std::env::temp_dir().join(format!(
1333 "supercov-ruby-evidence-{}-{nonce}-{name}",
1334 std::process::id()
1335 ));
1336 fs::create_dir_all(&path).unwrap();
1337 path
1338 }
1339
1340 #[test]
1341 fn declares_the_gap_when_a_process_never_reported() {
1342 let source = "def f(a)\n a\nend\n";
1349 let mut probe = 0;
1350 let obligations =
1351 build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
1352 let statement = obligations
1353 .manifest
1354 .points
1355 .iter()
1356 .find(|point| point.kind == PointKind::Statement)
1357 .unwrap();
1358 let base = [
1359 serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["child.rb"]}),
1360 serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
1361 serde_json::json!({"t":"hit","ctx":1,"id":statement.id}),
1362 serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":"passed","xfail":false,"runner":"minitest"}),
1363 ];
1364
1365 let killed = temporary("killed");
1366 fs::write(killed.join("main.7.a.mmap"), transport(&base)).unwrap();
1367 let run =
1368 build_ruby_frontend_run(&obligations.manifest, &killed, "run-1", "now", 0).unwrap();
1369 let declared = serde_json::to_string(&run.request.manifest.limitations).unwrap();
1370 assert!(
1371 declared.contains("ruby-process-did-not-report"),
1372 "a process that never reported must declare the gap: {declared}"
1373 );
1374 fs::remove_dir_all(&killed).unwrap();
1375
1376 let mut clean = base.to_vec();
1377 clean.push(serde_json::json!({"t":"exit","at":9}));
1378 let reported = temporary("reported");
1379 fs::write(reported.join("main.7.a.mmap"), transport(&clean)).unwrap();
1380 let run =
1381 build_ruby_frontend_run(&obligations.manifest, &reported, "run-1", "now", 0).unwrap();
1382 let declared = serde_json::to_string(&run.request.manifest.limitations).unwrap();
1383 assert!(
1384 !declared.contains("ruby-process-did-not-report"),
1385 "a process that reported cleanly must declare nothing: {declared}"
1386 );
1387 fs::remove_dir_all(&reported).unwrap();
1388 }
1389
1390 #[test]
1391 fn evidence_before_the_first_assertion_links_to_it_when_the_test_passes() {
1392 let source = "def f(a)\n a\nend\n\ndef g(b)\n b\nend\n";
1397 let mut probe = 0;
1398 let obligations =
1399 build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
1400 let statements: Vec<_> = obligations
1401 .manifest
1402 .points
1403 .iter()
1404 .filter(|point| point.kind == PointKind::Statement)
1405 .collect();
1406 let (before, after) = (&statements[0].id, &statements[1].id);
1407 let run_with = |name: &str, outcome: &str| {
1408 let records = [
1409 serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["test.rb"]}),
1410 serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
1411 serde_json::json!({"t":"hit","ctx":1,"id":before}),
1412 serde_json::json!({"t":"assert","ctx":1}),
1413 serde_json::json!({"t":"assert","ctx":1}),
1414 serde_json::json!({"t":"hit","ctx":1,"id":after}),
1415 serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":outcome,"xfail":false,"runner":"minitest"}),
1416 serde_json::json!({"t":"exit","at":9}),
1417 ];
1418 let directory = temporary(name);
1419 fs::write(directory.join("main.7.a.mmap"), transport(&records)).unwrap();
1420 let run = build_ruby_frontend_run(&obligations.manifest, &directory, "run-1", "now", 0)
1421 .unwrap();
1422 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
1423 fs::remove_dir_all(directory).unwrap();
1424 run
1425 };
1426 let events_of = |result: &RawTestResult, phase: &str| -> BTreeSet<String> {
1427 result
1428 .runtime
1429 .iter()
1430 .flat_map(|snapshot| snapshot.events.iter())
1431 .filter(|event| event.phase_id.as_deref() == Some(phase))
1432 .map(|event| event.id.clone())
1433 .collect()
1434 };
1435
1436 let run = run_with("asserted-passed", "passed");
1437 let passed = &run.request.raw_results[0];
1438 assert_eq!(passed.test, "MTest#test_x");
1439 let assertion = passed
1440 .phases
1441 .iter()
1442 .find(|phase| phase.kind == "assertion")
1443 .expect("the asserting test carries an assertion phase");
1444 assert_eq!(assertion.status.as_deref(), Some("passed"));
1445 assert_eq!(
1446 events_of(passed, &assertion.id),
1447 BTreeSet::from([before.clone()]),
1448 "only what ran before the marker is the assertion's evidence"
1449 );
1450 let test_phase = passed
1451 .phases
1452 .iter()
1453 .find(|phase| phase.kind == "test")
1454 .unwrap();
1455 assert_eq!(
1456 events_of(passed, &test_phase.id),
1457 BTreeSet::from([before.clone(), after.clone()]),
1458 "the test phase keeps everything it ran"
1459 );
1460 assert_eq!(
1461 run.declaration.runners[0].attribution.assertion,
1462 AttributionPrecision::Exact
1463 );
1464
1465 let run = run_with("asserted-failed", "failed");
1466 let failed = &run.request.raw_results[0];
1467 let assertion = failed
1468 .phases
1469 .iter()
1470 .find(|phase| phase.kind == "assertion")
1471 .unwrap();
1472 assert_eq!(
1473 assertion.status.as_deref(),
1474 Some("failed"),
1475 "a failed test's assertion witnessed nothing"
1476 );
1477 }
1478
1479 #[test]
1480 fn joins_rspec_and_minitest_outcomes_into_exact_results() {
1481 let source = "def f(a, b)\n if a && b\n 1\n else\n 0\n end\nend\n";
1482 let mut probe = 0;
1483 let obligations =
1484 build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
1485 let decision = &obligations.manifest.decisions[0];
1486 let statement = obligations
1487 .manifest
1488 .points
1489 .iter()
1490 .find(|point| point.kind == PointKind::Statement)
1491 .unwrap();
1492 let directory = temporary("join");
1493 let records = [
1494 serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["rspec"]}),
1495 serde_json::json!({"t":"hit","ctx":0,"id":statement.id}),
1496 serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"call"}),
1497 serde_json::json!({"t":"dec","ctx":1,"id":decision.id,"v":"22","o":1}),
1498 serde_json::json!({"t":"outcome","worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"setup","outcome":"passed","xfail":false,"runner":"rspec"}),
1499 serde_json::json!({"t":"outcome","worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"call","outcome":"passed","xfail":false,"runner":"rspec"}),
1500 serde_json::json!({"t":"outcome","worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"teardown","outcome":"passed","xfail":false,"runner":"rspec"}),
1501 serde_json::json!({"t":"phase","ctx":2,"at":6,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
1502 serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":"skipped","xfail":false,"runner":"minitest"}),
1503 serde_json::json!({"t":"exit","at":9}),
1504 ];
1505 fs::write(directory.join("main.7.a.mmap"), transport(&records)).unwrap();
1506 let run =
1507 build_ruby_frontend_run(&obligations.manifest, &directory, "run-1", "now", 0).unwrap();
1508 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
1509 assert_eq!(run.tests, 2);
1510 let runners = run
1511 .declaration
1512 .runners
1513 .iter()
1514 .map(|runner| runner.runner.clone())
1515 .collect::<Vec<_>>();
1516 assert_eq!(runners, ["minitest", "rspec"]);
1517 assert_eq!(run.ruby_versions, ["4.0.6"]);
1518 fs::remove_dir_all(directory).unwrap();
1519 }
1520
1521 #[test]
1522 fn fails_closed_without_an_interpreter() {
1523 let mut probe = 0;
1524 let obligations = build_ruby_obligations("m.rb", b"x = 1\n", &mut probe).unwrap();
1525 let directory = temporary("empty");
1526 assert!(matches!(
1527 build_ruby_frontend_run(&obligations.manifest, &directory, "run-1", "now", 0),
1528 Err(RubyEvidenceError::NoInterpreter)
1529 ));
1530 fs::remove_dir_all(directory).unwrap();
1531 }
1532}