Skip to content

Commit dbd002a

Browse files
committed
Output HTML artifact containing all failures:
Buildkite annotations, which this plugin creates, are limited to 100KB, and this plugin has a sophisticated algorithm for truncating the annotation to only the number of failures that would generate a Markdown-formatted annotation of less than that size. With stacktraces, that number can be as low as 15 failures: a small fraction of what a change to a large monorepo can cause. This change causes the plugin to also upload an artifact containing the HTML-ified non-truncated Markdown output, and link to that artifact from the annotation if the annotation has been truncated. This requires a refactor: Truncater now knows nothing about Formatter or Markdown; it only handles the truncation algorithm on the strings returned by a block. Much of the logic to wire Truncater and Formatters together has been moved to a new Processor class (this name was too similar to "Runner", so I renamed that to "Main"). This makes each class a bit more cohesive: Truncater only handles string truncation, Processor hands off the result of Formatter to Truncater, and Main writes the result of Processor to annotations and artifacts. Also: - I repurposed the `test_layout` template to also wrap the artifact, making the appearance similar to the annotation within Buildkite's UI. - There's now a mixin for classes that want to reraise-or-log, depending on the result of a `fail_on_error` method, and any error can now be accompanied by a "diagnostics" JSON object. - We now have a runtime dependency on a Markdown-to-HTML converter, so I switched to Kramdown, which is pure Ruby. The current minimal Dockerfile doesn't have the tools to compile C extensions.
1 parent 4628f22 commit dbd002a

18 files changed

Lines changed: 245 additions & 177 deletions

Gemfile

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }
66

77
gem 'bundler', '~> 1.16'
88
gem 'haml'
9+
gem 'kramdown'
910

1011
group :development, :test do
1112
gem 'rake', '~> 12.3'
@@ -14,10 +15,6 @@ group :development, :test do
1415
gem 'rubocop'
1516
end
1617

17-
group :development do
18-
gem 'commonmarker'
19-
end
20-
2118
group :test do
2219
gem 'simplecov', require: false
2320
end

Gemfile.lock

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,21 @@ GEM
22
remote: https://rubygems.org/
33
specs:
44
ast (2.4.0)
5-
commonmarker (0.17.9)
6-
ruby-enum (~> 0.5)
7-
concurrent-ruby (1.0.5)
85
diff-lcs (1.3)
96
docile (1.3.0)
107
haml (5.0.4)
118
temple (>= 0.8.0)
129
tilt
13-
i18n (1.0.1)
14-
concurrent-ruby (~> 1.0)
1510
json (2.1.0)
11+
kramdown (2.3.1)
12+
rexml
1613
parallel (1.12.1)
1714
parser (2.5.1.0)
1815
ast (~> 2.4.0)
1916
powerpack (0.1.1)
2017
rainbow (3.0.0)
2118
rake (12.3.3)
19+
rexml (3.2.4)
2220
rspec (3.7.0)
2321
rspec-core (~> 3.7.0)
2422
rspec-expectations (~> 3.7.0)
@@ -41,8 +39,6 @@ GEM
4139
rainbow (>= 2.2.2, < 4.0)
4240
ruby-progressbar (~> 1.7)
4341
unicode-display_width (~> 1.0, >= 1.0.1)
44-
ruby-enum (0.7.2)
45-
i18n
4642
ruby-progressbar (1.9.0)
4743
simplecov (0.16.1)
4844
docile (~> 1.1)
@@ -58,8 +54,8 @@ PLATFORMS
5854

5955
DEPENDENCIES
6056
bundler (~> 1.16)
61-
commonmarker
6257
haml
58+
kramdown
6359
rake (~> 12.3)
6460
rspec (~> 3.0)
6561
rspec_junit_formatter

bin/run-dev

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ require 'bundler/setup'
55
require 'pathname'
66
$LOAD_PATH.unshift(Pathname.new(__FILE__).dirname.dirname.join('lib').to_s)
77

8-
require 'commonmarker'
98
require 'yaml'
109
require 'test_summary_buildkite_plugin'
1110

@@ -20,8 +19,7 @@ module TestSummaryBuildkitePlugin
2019
log(args, stdin: stdin)
2120
if args.first == 'annotate'
2221
context = args[2]
23-
content = CommonMarker.render_html(stdin)
24-
html = HamlRender.render('test_layout', content: content)
22+
html = Utils.standalone_markdown(stdin)
2523
FileUtils.mkdir_p('tmp')
2624
out = Pathname.new('tmp').join(context + '.html')
2725
out.open('w') { |file| file.write(html) }

lib/test_summary_buildkite_plugin.rb

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
require 'json'
44

55
require 'test_summary_buildkite_plugin/agent'
6+
require 'test_summary_buildkite_plugin/error_handler'
67
require 'test_summary_buildkite_plugin/failure'
78
require 'test_summary_buildkite_plugin/formatter'
89
require 'test_summary_buildkite_plugin/haml_render'
910
require 'test_summary_buildkite_plugin/input'
10-
require 'test_summary_buildkite_plugin/runner'
11+
require 'test_summary_buildkite_plugin/main'
12+
require 'test_summary_buildkite_plugin/processor'
1113
require 'test_summary_buildkite_plugin/tap'
1214
require 'test_summary_buildkite_plugin/truncater'
1315
require 'test_summary_buildkite_plugin/utils'
@@ -18,6 +20,6 @@ def self.run
1820
plugins = JSON.parse(ENV.fetch('BUILDKITE_PLUGINS'), symbolize_names: true)
1921
# plugins is an array of hashes, keyed by <github-url>#<version>
2022
options = plugins.find { |k, _| k.to_s.include?('test-summary') }.values.first
21-
Runner.new(options).run
23+
Main.new(options).run
2224
end
2325
end
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# frozen_string_literal: true
2+
3+
module TestSummaryBuildkitePlugin
4+
module ErrorHandler
5+
def handle_error(err, diagnostics = nil)
6+
if fail_on_error
7+
raise err
8+
else
9+
Utils.log_error(err, diagnostics)
10+
end
11+
end
12+
end
13+
end

lib/test_summary_buildkite_plugin/formatter.rb

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,51 +4,55 @@
44

55
module TestSummaryBuildkitePlugin
66
class Formatter
7-
def self.create(**options)
7+
def self.create(input:, output_path:, options:)
88
options[:type] ||= 'details'
99
type = options[:type].to_sym
1010
raise "Unknown type: #{type}" unless TYPES.key?(type)
11-
TYPES[type].new(options)
11+
TYPES[type].new(input: input, output_path: output_path, options: options)
1212
end
1313

1414
class Base
15-
attr_reader :options
15+
attr_reader :input, :output_path, :options
1616

17-
def initialize(options = {})
17+
def initialize(input:, output_path:, options:)
18+
@input = input
19+
@output_path = output_path
1820
@options = options || {}
1921
end
2022

21-
def markdown(input)
23+
def markdown(truncate)
2224
return nil if input.failures.count.zero?
23-
[heading(input), input_markdown(input), footer(input)].compact.join("\n\n")
25+
[heading(truncate), input_markdown(truncate), footer].compact.join("\n\n")
2426
end
2527

2628
protected
2729

28-
def input_markdown(input)
29-
if show_first.negative? || show_first >= include_failures(input).count
30-
failures_markdown(include_failures(input))
30+
def input_markdown(truncate)
31+
failures = include_failures(truncate)
32+
33+
if show_first.negative? || show_first >= failures.count
34+
failures_markdown(failures, truncate)
3135
elsif show_first.zero?
32-
details('Show failures', failures_markdown(include_failures(input)))
36+
details('Show failures', failures_markdown(failures, truncate))
3337
else
34-
failures_markdown(include_failures(input)[0...show_first]) +
35-
details('Show additional failures', failures_markdown(include_failures(input)[show_first..-1]))
38+
failures_markdown(failures[0...show_first], false) +
39+
details('Show additional failures', failures_markdown(failures[show_first..-1], truncate))
3640
end
3741
end
3842

39-
def failures_markdown(failures)
40-
render_template('failures', failures: failures)
43+
def failures_markdown(failures, truncate)
44+
render_template('failures', failures: failures, output_path: truncate ? output_path : nil)
4145
end
4246

43-
def heading(input)
47+
def heading(truncate)
4448
count = input.failures.count
45-
show_count = include_failures(input).count
49+
show_count = include_failures(truncate).count
4650
s = "##### #{input.label}: #{count} failure#{'s' unless count == 1}"
4751
s += "\n\n_Including first #{show_count} failures_" if show_count < count
4852
s
4953
end
5054

51-
def footer(input)
55+
def footer
5256
job_ids = input.failures.map(&:job_id).uniq.reject(&:nil?)
5357
render_template('footer', job_ids: job_ids)
5458
end
@@ -65,11 +69,7 @@ def type
6569
options[:type] || 'details'
6670
end
6771

68-
def truncate
69-
options[:truncate]
70-
end
71-
72-
def include_failures(input)
72+
def include_failures(truncate)
7373
if truncate
7474
input.failures[0...truncate]
7575
else

lib/test_summary_buildkite_plugin/input.rb

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ def self.create(type:, **options)
1616
end
1717

1818
class Base
19+
include ErrorHandler
20+
1921
attr_reader :label, :artifact_path, :options
2022

2123
def initialize(label:, artifact_path:, **options)
@@ -77,14 +79,6 @@ def job_id_regex
7779
DEFAULT_JOB_ID_REGEX
7880
end
7981
end
80-
81-
def handle_error(err)
82-
if fail_on_error
83-
raise err
84-
else
85-
Utils.log_error(err)
86-
end
87-
end
8882
end
8983

9084
class OneLine < Base

lib/test_summary_buildkite_plugin/runner.rb renamed to lib/test_summary_buildkite_plugin/main.rb

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
# frozen_string_literal: true
22

33
module TestSummaryBuildkitePlugin
4-
class Runner
4+
class Main
55
MAX_MARKDOWN_SIZE = 100_000
6+
OUTPUT_PATH = 'test-summary.html'
67

78
attr_reader :options
89

@@ -11,23 +12,37 @@ def initialize(options)
1112
end
1213

1314
def run
14-
markdown = Truncater.new(
15+
processor = Processor.new(
16+
formatter_options: formatter,
1517
max_size: MAX_MARKDOWN_SIZE,
18+
output_path: OUTPUT_PATH,
1619
inputs: inputs,
17-
formatter_opts: options[:formatter],
1820
fail_on_error: fail_on_error
19-
).markdown
20-
if markdown.nil? || markdown.empty?
21+
)
22+
23+
if processor.truncated_markdown.nil? || processor.truncated_markdown.empty?
2124
puts('No errors found! 🎉')
2225
else
23-
annotate(markdown)
26+
upload_artifact(processor.inputs_markdown)
27+
annotate(processor.truncated_markdown)
2428
end
2529
end
2630

31+
private
32+
33+
def upload_artifact(markdown)
34+
File.write(OUTPUT_PATH, Utils.standalone_markdown(markdown))
35+
Agent.run('artifact', 'upload', OUTPUT_PATH)
36+
end
37+
2738
def annotate(markdown)
2839
Agent.run('annotate', '--context', context, '--style', style, stdin: markdown)
2940
end
3041

42+
def formatter
43+
options[:formatter] || {}
44+
end
45+
3146
def inputs
3247
@inputs ||= options[:inputs].map { |opts| Input.create(opts.merge(fail_on_error: fail_on_error)) }
3348
end
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# frozen_string_literal: true
2+
3+
module TestSummaryBuildkitePlugin
4+
class Processor
5+
include ErrorHandler
6+
7+
attr_reader :formatter_options, :max_size, :output_path, :inputs, :fail_on_error
8+
9+
def initialize(formatter_options:, max_size:, output_path:, inputs:, fail_on_error:)
10+
@formatter_options = formatter_options
11+
@max_size = max_size
12+
@output_path = output_path
13+
@inputs = inputs
14+
@fail_on_error = fail_on_error
15+
@_formatters = {}
16+
end
17+
18+
def truncated_markdown
19+
@truncated_markdown ||= begin
20+
truncater = Truncater.new(
21+
max_size: max_size,
22+
max_truncate: inputs.map(&:failures).map(&:count).max
23+
) do |truncate|
24+
inputs_markdown(truncate)
25+
end
26+
27+
truncater.markdown
28+
rescue StandardError => e
29+
handle_error(e, diagnostics)
30+
HamlRender.render('truncater_exception', {})
31+
end
32+
end
33+
34+
def inputs_markdown(truncate = nil)
35+
inputs.map { |input| input_markdown(input, truncate) }.compact.join("\n\n")
36+
end
37+
38+
private
39+
40+
def input_markdown(input, truncate)
41+
formatter(input).markdown(truncate)
42+
rescue StandardError => e
43+
handle_error(e)
44+
end
45+
46+
def formatter(input)
47+
@_formatters[input] ||= Formatter.create(input: input, output_path: output_path, options: formatter_options)
48+
end
49+
50+
def diagnostics
51+
{
52+
formatter: formatter_options,
53+
inputs: inputs.map do |input|
54+
{
55+
type: input.class,
56+
failure_count: input.failures.count,
57+
markdown_bytesize: input_markdown(input, nil)&.bytesize
58+
}
59+
end
60+
}
61+
end
62+
end
63+
end

0 commit comments

Comments
 (0)