75 / 91 lines covered (82.4%)
1unit module BDD::Behave::Bisect;23use BDD::Behave::Colors;45our class BisectResult {6 has Str @.initial-failing;7 has %.minimal-deps;8 has Int $.iterations = 0;9 has Bool $.had-failures = False;10 has Str $.message;11}1213our class Bisector {14 has @.spec-files;15 has Str $.behave-bin = 'bin/behave';16 has @.extra-args;17 has Bool $.verbose = False;18 has Bool $.quiet = False;1920 has Int $!iterations = 0;2122 method iterations(--> Int) { $!iterations }2324 method run(--> BisectResult) {25 self!log: "==> Bisect: initial pass";26 my %initial = self!subprocess-run(:no-only);27 my @executed = %initial<executed>.list.cache;28 my @failed = %initial<failed>.list.cache;2930 unless @failed.elems {31 self!log: "Bisect: no failing examples in the initial run. Nothing to do.";32 return BisectResult.new(33 :initial-failing(()),34 :had-failures(False),35 :iterations($!iterations),36 :message('no failures'),37 );38 }3940 self!log: "Bisect: {@failed.elems} failing example(s) found across {@executed.elems} executed";41 for @failed -> $f { self!log: " " ~ red("✗ ") ~ $f }4243 my %minimal-deps;44 for @failed -> $failing {45 self!log: "";46 self!log: "==> Bisecting $failing";4748 my $idx = @executed.first(* eq $failing, :k);49 unless $idx.defined {50 self!log: " WARN: $failing not present in execution order, skipping";51 %minimal-deps{$failing} = [];52 next;53 }5455 my @prior = @executed[0 ..^ $idx].grep(* ne $failing).List;5657 if @prior.elems == 0 {58 self!log: " No prior examples — failure is independent of order";59 %minimal-deps{$failing} = [];60 next;61 }6263 my %isolation = self!subprocess-run(:only-locations([$failing]));64 if %isolation<failed>.first(* eq $failing).defined {65 self!log: " Failure reproduces in isolation — not order-dependent";66 %minimal-deps{$failing} = [];67 next;68 }6970 my %full = self!subprocess-run(:only-locations([|@prior, $failing]));71 unless %full<failed>.first(* eq $failing).defined {72 self!log: " Failure does not reproduce under --only-example replay; cannot bisect";73 %minimal-deps{$failing} = @prior;74 next;75 }7677 my @minimal = self!minimize($failing, @prior);78 %minimal-deps{$failing} = @minimal;7980 self!log: "";81 self!log: " Minimal reproduction ({@minimal.elems} prior + 1 failing):";82 for @minimal -> $m { self!log: " " ~ light-blue($m) }83 self!log: " " ~ red($failing) ~ " (failing)";84 self!log: "";85 self!log: " Reproduce with:";86 my $cmd = " bin/behave"87 ~ @minimal.map({ " --only-example $_" }).join88 ~ " --only-example $failing"89 ~ " --order defined"90 ~ @!spec-files.map({ " $_" }).join;91 self!log: $cmd;92 }9394 self!log: "";95 self!log: "Bisect complete: {$!iterations} iteration(s)";9697 BisectResult.new(98 :initial-failing(@failed),99 :%minimal-deps,100 :had-failures(True),101 :iterations($!iterations),102 );103 }104105 method !minimize(Str $failing, @prior --> List) {106 my @current = @prior.List;107108 loop {109 last if @current.elems <= 1;110 my $mid = @current.elems div 2;111 my @left = @current[0 ..^ $mid].List;112 my @right = @current[$mid .. *].List;113114 my %right-result = self!subprocess-run(115 :only-locations([|@right, $failing]),116 );117 if %right-result<failed>.first(* eq $failing).defined {118 @current = @right;119 self!log: " shrunk to {@current.elems} prior";120 next;121 }122123 my %left-result = self!subprocess-run(124 :only-locations([|@left, $failing]),125 );126 if %left-result<failed>.first(* eq $failing).defined {127 @current = @left;128 self!log: " shrunk to {@current.elems} prior";129 next;130 }131132 last;133 }134135 my Bool $changed = True;136 while $changed && @current.elems > 1 {137 $changed = False;138 for ^@current.elems -> $i {139 my @candidate;140 @candidate.append: @current[0 ..^ $i];141 @candidate.append: @current[$i + 1 .. *];142 my %result = self!subprocess-run(143 :only-locations([|@candidate, $failing]),144 );145 if %result<failed>.first(* eq $failing).defined {146 @current = @candidate;147 $changed = True;148 self!log: " pruned one; {@current.elems} prior remain";149 last;150 }151 }152 }153154 @current.List;155 }156157 method !subprocess-run(:@only-locations = [], :$no-only = False --> Hash) {158 $!iterations++;159160 my @args = '--bisect-data', '--order', 'defined';161 unless $no-only {162 for @only-locations -> $loc {163 @args.append: '--only-example', $loc;164 }165 }166 @args.append: @!extra-args;167 @args.append: @!spec-files;168169 if $!verbose {170 note " [iter $!iterations] raku -Ilib $!behave-bin {@args.join(' ')}";171 }172173 my $proc = run('raku', '-Ilib', $!behave-bin, |@args, :out, :err);174 my $out = $proc.out.slurp(:close);175 my $err = $proc.err.slurp(:close);176177 my @executed;178 my @failed;179 for $out.lines -> $line {180 if $line.starts-with('behave-executed: ') {181 @executed.push: $line.substr('behave-executed: '.chars);182 } elsif $line.starts-with('behave-failed: ') {183 @failed.push: $line.substr('behave-failed: '.chars);184 }185 }186187 %( :@executed, :@failed, :exit($proc.exitcode), :$out, :$err );188 }189190 method !log(Str $msg) {191 say $msg unless $!quiet;192 }193}