109 / 118 lines covered (92.4%)
1use BDD::Behave::Failures;2use BDD::Behave::Formatter;34unit class BDD::Behave::Formatter::HTML does BDD::Behave::Formatter;56has @!fragments;7has Bool $!multi-file = False;8has Bool $!emitted = False;9has Int $!failure-watermark = 0;10has Str $!pending-auto-description;11has Real $!started-at;1213method name(--> Str) { 'html' }1415method suite-loading(Str :$file) { }1617method suite-start($suite, Bool :$multi-file = False) {18 $!multi-file = $multi-file;19 $!started-at //= now.Real;20 if $multi-file {21 my $file = $suite.file.basename;22 @!fragments.push: '<h2 class="suite-file">' ~ self!escape($file) ~ '</h2>';23 }24}2526method suite-end($suite) { }2728method group-start($group) {29 @!fragments.push: '<details open class="group">'30 ~ '<summary class="group-summary">'31 ~ self!escape($group.description)32 ~ '</summary>'33 ~ '<div class="group-body">';34}3536method group-end($group) {37 @!fragments.push: '</div></details>';38}3940method group-around-skipped($group) {41 @!fragments.push: '<div class="example skipped">'42 ~ '<span class="marker">⊘</span> '43 ~ '<span class="desc">(group skipped: around-all did not invoke continuation)</span>'44 ~ '</div>';45}4647method example-start($example, Bool :$auto = False) {48 $!failure-watermark = Failures.list.elems;49 $!pending-auto-description = Str;50}5152method example-auto-description($example, Str :$description) {53 $!pending-auto-description = $description;54}5556method !escape(Str $s --> Str) {57 return '' unless $s.defined;58 my $r = $s;59 $r = $r.subst('&', '&', :g);60 $r = $r.subst('<', '<', :g);61 $r = $r.subst('>', '>', :g);62 $r = $r.subst('"', '"',:g);63 $r;64}6566method !location($example --> Str) {67 '<span class="location">'68 ~ self!escape("{$example.file}:{$example.line}")69 ~ '</span>';70}7172method !duration-span($example --> Str) {73 return '' unless $example.duration.defined;74 '<span class="duration">'75 ~ sprintf('%.3fs', $example.duration)76 ~ '</span>';77}7879method !render-example($example, Str $status, Str $marker, Str :$detail) {80 my $description = $!pending-auto-description // $example.description;81 my $body = '<div class="example ' ~ $status ~ '">'82 ~ '<span class="marker">' ~ $marker ~ '</span> '83 ~ '<span class="desc">' ~ self!escape($description) ~ '</span> '84 ~ self!duration-span($example)85 ~ ' ' ~ self!location($example);86 $body ~= '<pre class="failure-detail">' ~ self!escape($detail) ~ '</pre>'87 if $detail.defined && $detail.chars;88 $body ~= '</div>';89 @!fragments.push: $body;90}9192method example-pass($example) {93 self!render-example($example, 'pass', '✓');94 $!pending-auto-description = Str;95}9697method example-fail($example, :$failure-info) {98 my @lines;99 if $failure-info.defined && $failure-info<exception>.defined {100 @lines.push: 'Exception: ' ~ $failure-info<exception>.message;101 }102 my $count = Failures.list.elems;103 if $count > $!failure-watermark {104 for Failures.list[$!failure-watermark .. $count - 1] -> $f {105 next if $f.from-runner-exception;106 @lines.push: "{$f.file}:{$f.line}";107 if $f.message.defined {108 @lines.push: " $_" for $f.message.lines;109 } else {110 my $op = $f.negated ?? 'not to be' !! 'to be';111 @lines.push: " Expected: {$f.given.gist}";112 @lines.push: " $op: {$f.expected.gist}";113 }114 }115 }116 self!render-example($example, 'fail', '✗', :detail(@lines.join("\n")));117 $!pending-auto-description = Str;118}119120method example-pending($example) {121 my $reason = $example.get-metadata('pending-reason') // 'pending';122 self!render-example($example, 'pending', '⏸',123 :detail("pending: $reason"));124}125126method example-skipped($example) {127 self!render-example($example, 'skipped', '⊘');128}129130method example-around-skipped($example) {131 self!render-example($example, 'skipped', '⊘',132 :detail('around-each did not invoke continuation'));133}134135method example-slow($example, Real :$threshold) { }136137method run-summary(138 $result,139 Bool :$aborted = False,140 Int :$fail-fast = 0,141 Str :$order = 'defined',142 Int :$seed,143) {144 return if $!multi-file;145 self!emit($result, :$aborted, :$order, :$seed);146}147148method profile-summary(@records, Int :$limit) { }149150method benchmark-summary-section(151 @summaries, @regressions,152 Real :$threshold,153 Str :$format,154 IO::Path :$output,155 :$runner,156) { }157158method multi-file-overall($result, Str :$order = 'defined', Int :$seed) {159 self!emit($result, :$order, :$seed);160}161162method multi-file-profile($runner, @records, Int :$limit) { }163method multi-file-benchmark(164 $runner,165 @summaries, @regressions,166 Real :$threshold,167 Str :$format,168 IO::Path :$output,169) { }170171method load-errors(@errors) {172 for @errors -> $err {173 my $file = ($err<file> // '').Str;174 my $message = ($err<message> // '').Str;175 @!fragments.push: '<div class="load-error">'176 ~ '<strong>Load error in ' ~ self!escape($file) ~ '</strong>'177 ~ '<pre>' ~ self!escape($message) ~ '</pre>'178 ~ '</div>';179 }180}181182method !summary-line($result --> Str) {183 my @parts;184 @parts.push: "{$result.total} example" ~ ($result.total == 1 ?? '' !! 's');185 @parts.push: "{$result.failed} failed" if $result.failed > 0;186 @parts.push: "{$result.pending} pending" if $result.pending > 0;187 @parts.push: "{$result.skipped} skipped" if $result.skipped > 0;188 @parts.push: "{$result.passed} passed" if $result.passed > 0;189 @parts.join(', ');190}191192method !style(--> Str) {193 q:to/CSS/;194body { font-family: -apple-system, sans-serif; margin: 1em; color: #222; }195h1 { margin-bottom: 0.2em; }196.summary { font-weight: bold; padding: 0.5em; background: #f0f0f0; border-radius: 4px; }197.summary.has-failures { background: #fee; }198.suite-file { margin-top: 1em; }199.group { margin-left: 1em; padding-left: 0.5em; border-left: 1px solid #eee; }200.group > .group-summary { font-weight: bold; cursor: pointer; padding: 0.2em 0; }201.group-body { margin-left: 1em; }202.example { padding: 0.15em 0.3em; margin: 0.15em 0; font-family: monospace; }203.example .marker { display: inline-block; width: 1.2em; text-align: center; font-weight: bold; }204.example.pass { color: #1b8a3a; }205.example.fail { color: #b30000; background: #fff5f5; }206.example.pending { color: #1370c4; background: #f5faff; }207.example.skipped { color: #777; }208.duration { color: #999; font-size: 0.85em; margin-left: 0.5em; }209.location { color: #aaa; font-size: 0.85em; margin-left: 0.5em; }210.failure-detail { background: #fff0f0; border-left: 3px solid #b30000; padding: 0.5em; margin: 0.3em 0 0.3em 1em; white-space: pre-wrap; }211.load-error { background: #fee; padding: 0.5em; margin: 0.5em 0; border-radius: 4px; }212CSS213}214215method !emit($result, Bool :$aborted = False, Str :$order = 'defined', Int :$seed) {216 return if $!emitted;217 $!emitted = True;218219 my $duration = $!started-at.defined ?? (now.Real - $!started-at) !! 0e0;220 my $summary = self!summary-line($result);221 my $summary-class = $result.failed > 0 ?? 'summary has-failures' !! 'summary';222223 say '<!DOCTYPE html>';224 say '<html lang="en">';225 say '<head>';226 say '<meta charset="utf-8">';227 say '<title>Behave Test Report</title>';228 say '<style>';229 print self!style;230 say '</style>';231 say '</head>';232 say '<body>';233 say '<h1>Behave Test Report</h1>';234 say '<p class="' ~ $summary-class ~ '">' ~ self!escape($summary)235 ~ ' (' ~ sprintf('%.3fs', $duration) ~ ')'236 ~ '</p>';237 if $order eq 'random' && $seed.defined {238 say '<p class="meta">Randomized with seed ' ~ $seed ~ '</p>';239 }240 if $aborted {241 say '<p class="meta aborted">Run was aborted.</p>';242 }243244 say '<div class="results">';245 for @!fragments -> $frag { say $frag }246 say '</div>';247 say '</body>';248 say '</html>';249}