henitai 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +106 -1
- data/README.md +125 -3
- data/assets/schema/henitai.schema.json +35 -0
- data/lib/henitai/available_cpu_count.rb +16 -23
- data/lib/henitai/canonical_report_merger.rb +107 -0
- data/lib/henitai/canonical_report_writer.rb +22 -0
- data/lib/henitai/checkpoint_reporter.rb +79 -0
- data/lib/henitai/cli/clean_command.rb +14 -8
- data/lib/henitai/cli/command_support.rb +2 -1
- data/lib/henitai/cli/operator_command.rb +2 -1
- data/lib/henitai/cli/options.rb +21 -57
- data/lib/henitai/cli/run_command.rb +36 -3
- data/lib/henitai/cli/run_options.rb +108 -0
- data/lib/henitai/cli.rb +12 -4
- data/lib/henitai/composite_progress_reporter.rb +42 -0
- data/lib/henitai/configuration.rb +41 -9
- data/lib/henitai/configuration_validator/rules.rb +18 -0
- data/lib/henitai/configuration_validator/scalars.rb +46 -0
- data/lib/henitai/configuration_validator.rb +8 -1
- data/lib/henitai/coverage_bootstrapper.rb +54 -5
- data/lib/henitai/coverage_formatter.rb +5 -1
- data/lib/henitai/coverage_report_reader.rb +28 -5
- data/lib/henitai/execution_engine/env_scope.rb +67 -0
- data/lib/henitai/execution_engine.rb +111 -50
- data/lib/henitai/generated_artifacts.rb +58 -0
- data/lib/henitai/incremental_filter.rb +100 -0
- data/lib/henitai/integration/child_debug_support.rb +6 -2
- data/lib/henitai/integration/coverage_suppression.rb +9 -0
- data/lib/henitai/integration/minitest.rb +11 -43
- data/lib/henitai/integration/minitest_load_path.rb +14 -0
- data/lib/henitai/integration/minitest_suite_command.rb +17 -0
- data/lib/henitai/integration/minitest_test_runner.rb +39 -0
- data/lib/henitai/integration/rails_environment_preloader.rb +14 -0
- data/lib/henitai/integration/rspec_child_runner.rb +3 -3
- data/lib/henitai/integration/rspec_test_selection.rb +3 -0
- data/lib/henitai/integration/scenario_log_support.rb +67 -20
- data/lib/henitai/minitest_coverage_reporter.rb +4 -1
- data/lib/henitai/mutant/activator.rb +14 -0
- data/lib/henitai/mutant.rb +13 -6
- data/lib/henitai/mutant_history_store/sql.rb +21 -3
- data/lib/henitai/mutant_history_store/verdict_cache.rb +84 -0
- data/lib/henitai/mutant_history_store.rb +62 -12
- data/lib/henitai/mutant_identity.rb +33 -2
- data/lib/henitai/mutation_skip_directives.rb +227 -0
- data/lib/henitai/operator.rb +3 -1
- data/lib/henitai/operators/equality_identity_operator.rb +51 -0
- data/lib/henitai/operators/equality_operator.rb +8 -3
- data/lib/henitai/operators.rb +1 -0
- data/lib/henitai/per_test_coverage.rb +81 -0
- data/lib/henitai/per_test_coverage_collector.rb +20 -5
- data/lib/henitai/per_test_coverage_selector.rb +11 -33
- data/lib/henitai/reporter/dashboard_metadata_provider.rb +113 -0
- data/lib/henitai/reporter.rb +248 -117
- data/lib/henitai/reports_directory_lock.rb +76 -0
- data/lib/henitai/result.rb +75 -10
- data/lib/henitai/runner.rb +102 -43
- data/lib/henitai/scenario_execution_result.rb +12 -0
- data/lib/henitai/slot_scheduler/draining.rb +21 -15
- data/lib/henitai/slot_scheduler.rb +73 -15
- data/lib/henitai/static_filter.rb +16 -6
- data/lib/henitai/survivor_rerun_strategy.rb +1 -1
- data/lib/henitai/survivor_test_filter.rb +1 -1
- data/lib/henitai/test_prioritizer.rb +28 -2
- data/lib/henitai/timeout_calibrator.rb +44 -0
- data/lib/henitai/verdict_fingerprint.rb +155 -0
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +14 -1
- data/sig/henitai.rbs +235 -25
- metadata +22 -3
- data/lib/henitai/parallel_execution_runner.rb +0 -153
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
module Henitai
|
|
4
4
|
# Narrows candidate test files using the per-test coverage report.
|
|
5
|
+
#
|
|
6
|
+
# The intersection check itself lives in {PerTestCoverage}; this class only
|
|
7
|
+
# adds the selection policy: when no candidate provably covers the mutant
|
|
8
|
+
# (or the map / location is unavailable), it falls back to all candidates —
|
|
9
|
+
# over-selection costs time, never correctness.
|
|
5
10
|
class PerTestCoverageSelector
|
|
6
11
|
def initialize(coverage_report_reader: CoverageReportReader.new)
|
|
7
12
|
@coverage_report_reader = coverage_report_reader
|
|
@@ -10,49 +15,22 @@ module Henitai
|
|
|
10
15
|
def filter(tests, mutant, reports_dir:)
|
|
11
16
|
candidates = Array(tests)
|
|
12
17
|
return candidates if candidates.empty?
|
|
13
|
-
return candidates unless location_available?(mutant)
|
|
14
|
-
return candidates unless per_test_coverage_available?(reports_dir)
|
|
15
18
|
|
|
19
|
+
coverage = per_test_coverage(reports_dir)
|
|
16
20
|
covered_tests = candidates.select do |test|
|
|
17
|
-
|
|
21
|
+
coverage.covers?(test, mutant)
|
|
18
22
|
end
|
|
19
23
|
covered_tests.empty? ? candidates : covered_tests
|
|
20
24
|
end
|
|
21
25
|
|
|
22
26
|
private
|
|
23
27
|
|
|
24
|
-
def location_available?(mutant)
|
|
25
|
-
mutant.respond_to?(:location) &&
|
|
26
|
-
mutant.location.is_a?(Hash) &&
|
|
27
|
-
mutant.location[:file] &&
|
|
28
|
-
mutant.location[:start_line] &&
|
|
29
|
-
mutant.location[:end_line]
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def covers_mutant?(test, mutant, reports_dir)
|
|
33
|
-
covered_lines = coverage_lines_for(test, mutant, reports_dir)
|
|
34
|
-
mutant_lines(mutant).any? { |line| covered_lines.include?(line) }
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def coverage_lines_for(test, mutant, reports_dir)
|
|
38
|
-
source_map = per_test_coverage(reports_dir)[test.to_s] || {}
|
|
39
|
-
Array(source_map[File.expand_path(mutant.location[:file])]).uniq
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def mutant_lines(mutant)
|
|
43
|
-
(mutant.location[:start_line]..mutant.location[:end_line]).to_a
|
|
44
|
-
end
|
|
45
|
-
|
|
46
28
|
def per_test_coverage(reports_dir)
|
|
47
29
|
@per_test_coverage ||= {}
|
|
48
|
-
@per_test_coverage[reports_dir] ||=
|
|
49
|
-
|
|
50
|
-
coverage_report_reader
|
|
51
|
-
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def per_test_coverage_available?(reports_dir)
|
|
55
|
-
!per_test_coverage(reports_dir).empty?
|
|
30
|
+
@per_test_coverage[reports_dir] ||= PerTestCoverage.new(
|
|
31
|
+
reports_dir:,
|
|
32
|
+
coverage_report_reader:
|
|
33
|
+
)
|
|
56
34
|
end
|
|
57
35
|
|
|
58
36
|
attr_reader :coverage_report_reader
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Henitai
|
|
7
|
+
module Reporter
|
|
8
|
+
# Resolves the Stryker Dashboard project/version/api-key coordinates for
|
|
9
|
+
# {Dashboard}. Isolated behind injectable +env+/+git_executor+ seams so
|
|
10
|
+
# callers (and specs) never have to touch real ENV or shell out to git.
|
|
11
|
+
class DashboardMetadataProvider
|
|
12
|
+
def initialize(dashboard_config:, env: ENV, git_executor: Open3)
|
|
13
|
+
@dashboard_config = dashboard_config || {}
|
|
14
|
+
@env = env
|
|
15
|
+
@git_executor = git_executor
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def project
|
|
19
|
+
@project ||= dashboard_config[:project] || project_from_git_remote
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def version
|
|
23
|
+
@version ||= env_version || git_branch_name
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def api_key
|
|
27
|
+
env.fetch("STRYKER_DASHBOARD_API_KEY", nil)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
def project_from_git_url(url)
|
|
32
|
+
normalized = normalize_git_url(url)
|
|
33
|
+
return nil if normalized.nil?
|
|
34
|
+
|
|
35
|
+
return project_from_uri_url(normalized) if normalized.include?("://")
|
|
36
|
+
return project_from_ssh_url(normalized) if normalized.include?("@")
|
|
37
|
+
|
|
38
|
+
normalized
|
|
39
|
+
rescue URI::InvalidURIError
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def normalize_git_url(url)
|
|
44
|
+
return nil if url.nil? || url.strip.empty?
|
|
45
|
+
|
|
46
|
+
url.strip.sub(/\.git\z/, "")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def project_from_uri_url(normalized)
|
|
50
|
+
uri = URI.parse(normalized)
|
|
51
|
+
path = uri.path.to_s.sub(%r{^/}, "")
|
|
52
|
+
[uri.host, path].compact.reject(&:empty?).join("/")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def project_from_ssh_url(normalized)
|
|
56
|
+
_, host_and_path = normalized.split("@", 2)
|
|
57
|
+
return nil if host_and_path.nil?
|
|
58
|
+
|
|
59
|
+
host, path = host_and_path.split(":", 2)
|
|
60
|
+
return nil unless host && path
|
|
61
|
+
|
|
62
|
+
"#{host}/#{path}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
attr_reader :dashboard_config, :env, :git_executor
|
|
69
|
+
|
|
70
|
+
def env_version
|
|
71
|
+
ref_name = env.fetch("GITHUB_REF_NAME", nil)
|
|
72
|
+
return ref_name unless blank?(ref_name)
|
|
73
|
+
|
|
74
|
+
ref = env.fetch("GITHUB_REF", nil)
|
|
75
|
+
return ref_without_prefix(ref) unless ref.nil? || blank?(ref)
|
|
76
|
+
|
|
77
|
+
env.fetch("GITHUB_SHA", nil)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def ref_without_prefix(ref)
|
|
81
|
+
return nil if blank?(ref)
|
|
82
|
+
|
|
83
|
+
ref.to_s.sub(%r{^refs/(heads|tags|pull)/}, "")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def project_from_git_remote
|
|
87
|
+
self.class.project_from_git_url(git_remote_url)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def git_remote_url
|
|
91
|
+
stdout, status = git_executor.capture2("git", "remote", "get-url", "origin")
|
|
92
|
+
return stdout.strip if status.success?
|
|
93
|
+
|
|
94
|
+
nil
|
|
95
|
+
rescue Errno::ENOENT
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def git_branch_name
|
|
100
|
+
stdout, status = git_executor.capture2("git", "rev-parse", "--abbrev-ref", "HEAD")
|
|
101
|
+
return stdout.strip if status.success? && !stdout.strip.empty?
|
|
102
|
+
|
|
103
|
+
nil
|
|
104
|
+
rescue Errno::ENOENT
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def blank?(value)
|
|
109
|
+
value.nil? || value.strip.empty?
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
data/lib/henitai/reporter.rb
CHANGED
|
@@ -6,6 +6,7 @@ require "net/http"
|
|
|
6
6
|
require "open3"
|
|
7
7
|
require "uri"
|
|
8
8
|
require_relative "unparse_helper"
|
|
9
|
+
require_relative "reporter/dashboard_metadata_provider"
|
|
9
10
|
|
|
10
11
|
module Henitai
|
|
11
12
|
# Namespace for result reporters.
|
|
@@ -34,7 +35,7 @@ module Henitai
|
|
|
34
35
|
def self.reporter_class(name)
|
|
35
36
|
const_get(name.capitalize)
|
|
36
37
|
rescue NameError
|
|
37
|
-
raise ArgumentError, "Unknown reporter: #{name}. Valid reporters: terminal, json, html, dashboard"
|
|
38
|
+
raise ArgumentError, "Unknown reporter: #{name}. Valid reporters: terminal, json, html, dashboard, github"
|
|
38
39
|
end
|
|
39
40
|
|
|
40
41
|
# Base class for all reporters.
|
|
@@ -55,6 +56,21 @@ module Henitai
|
|
|
55
56
|
private
|
|
56
57
|
|
|
57
58
|
attr_reader :config, :history_store
|
|
59
|
+
|
|
60
|
+
# Authoritative (full) runs fully replace the canonical report;
|
|
61
|
+
# scoped/partial runs merge into it (CanonicalReportMerger) so
|
|
62
|
+
# findings for files outside this run's scope aren't lost.
|
|
63
|
+
def authoritative?(result)
|
|
64
|
+
return true unless result.respond_to?(:authoritative?)
|
|
65
|
+
|
|
66
|
+
result.authoritative?
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The merge source of truth is always the JSON canonical report, even
|
|
70
|
+
# for the HTML reporter, which embeds the same merged schema.
|
|
71
|
+
def canonical_path
|
|
72
|
+
File.join(config.reports_dir, "mutation-report.json")
|
|
73
|
+
end
|
|
58
74
|
end
|
|
59
75
|
|
|
60
76
|
# Terminal reporter.
|
|
@@ -68,6 +84,11 @@ module Henitai
|
|
|
68
84
|
ignored: "I"
|
|
69
85
|
}.freeze
|
|
70
86
|
|
|
87
|
+
def initialize(config:, history_store: nil, color_enabled: !ENV.key?("NO_COLOR"))
|
|
88
|
+
super(config:, history_store:)
|
|
89
|
+
@color_enabled = color_enabled
|
|
90
|
+
end
|
|
91
|
+
|
|
71
92
|
def report(result)
|
|
72
93
|
puts report_lines(result)
|
|
73
94
|
end
|
|
@@ -88,6 +109,8 @@ module Henitai
|
|
|
88
109
|
|
|
89
110
|
private
|
|
90
111
|
|
|
112
|
+
attr_reader :color_enabled
|
|
113
|
+
|
|
91
114
|
def report_lines(result)
|
|
92
115
|
lines = summary_lines(result)
|
|
93
116
|
detail_lines = survived_detail_lines(result)
|
|
@@ -112,8 +135,52 @@ module Henitai
|
|
|
112
135
|
format_row("Survived", count_status(result, :survived)),
|
|
113
136
|
format_row("Timeout", count_status(result, :timeout)),
|
|
114
137
|
format_row("No coverage", count_status(result, :no_coverage)),
|
|
115
|
-
format_row("Duration", format_duration(result.duration))
|
|
116
|
-
|
|
138
|
+
format_row("Duration", format_duration(result.duration)),
|
|
139
|
+
reused_verdicts_line(result),
|
|
140
|
+
executed_only_score_line(result),
|
|
141
|
+
empty_since_scope_line(result)
|
|
142
|
+
].compact
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# A zero-mutant summary with every counter at 0 and n/a scores is
|
|
146
|
+
# baffling without its cause; name it when the run was scoped by --since.
|
|
147
|
+
def empty_since_scope_line(result)
|
|
148
|
+
return nil unless result.mutants.empty?
|
|
149
|
+
return nil unless result.respond_to?(:since) && result.since
|
|
150
|
+
|
|
151
|
+
"No mutants: no configured source files changed since #{result.since}."
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def reused_verdicts_line(result)
|
|
155
|
+
reused = reused_mutants(result)
|
|
156
|
+
return nil if reused.empty?
|
|
157
|
+
|
|
158
|
+
format(
|
|
159
|
+
"%<reused>d of %<total>d verdicts reused from history (%<killed>d killed, %<survived>d survived)",
|
|
160
|
+
reused: reused.size,
|
|
161
|
+
total: result.mutants.size,
|
|
162
|
+
killed: reused.count { |mutant| mutant.status == :killed },
|
|
163
|
+
survived: reused.count { |mutant| mutant.status == :survived }
|
|
164
|
+
)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def reused_mutants(result)
|
|
168
|
+
result.mutants.select { |mutant| mutant.respond_to?(:from_cache?) && mutant.from_cache? }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Cached verdicts make the combined MS/MSI partly synthetic; the
|
|
172
|
+
# executed-only pair keeps this run's own signal visible.
|
|
173
|
+
def executed_only_score_line(result)
|
|
174
|
+
return nil unless result.respond_to?(:executed_scoring_summary)
|
|
175
|
+
|
|
176
|
+
summary = result.executed_scoring_summary # : Hash[Symbol, untyped]?
|
|
177
|
+
return nil unless summary
|
|
178
|
+
|
|
179
|
+
format(
|
|
180
|
+
"Executed-only MS %<score>s | MSI %<indicator>s",
|
|
181
|
+
score: format_percent(summary[:mutation_score]),
|
|
182
|
+
indicator: format_percent(summary[:mutation_score_indicator])
|
|
183
|
+
)
|
|
117
184
|
end
|
|
118
185
|
|
|
119
186
|
def partial_summary_lines(result)
|
|
@@ -167,19 +234,43 @@ module Henitai
|
|
|
167
234
|
end
|
|
168
235
|
|
|
169
236
|
def mutated_line(mutant)
|
|
170
|
-
format("+ %s",
|
|
237
|
+
format("+ %s", display_mutated_source(mutant))
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Prefer the mutant's memoized unparse (shared with the JSON reporter);
|
|
241
|
+
# string literals keep the visible-escape rendering below.
|
|
242
|
+
def display_mutated_source(mutant)
|
|
243
|
+
node = mutant.mutated_node
|
|
244
|
+
return node.children.first.inspect if str_node?(node)
|
|
245
|
+
return mutant.mutated_source if mutant.respond_to?(:mutated_source)
|
|
246
|
+
|
|
247
|
+
display_unparse(node)
|
|
171
248
|
end
|
|
172
249
|
|
|
173
250
|
# Like safe_unparse but makes invisible characters visible in terminal
|
|
174
251
|
# output. For string literal nodes the inner value is shown via #inspect
|
|
175
|
-
# so that e.g. "" vs " " vs "\n" are unambiguous.
|
|
176
|
-
#
|
|
252
|
+
# so that e.g. "" vs " " vs "\n" are unambiguous. Nodes parsed from real
|
|
253
|
+
# source render their original source slice — unparsing is expensive and
|
|
254
|
+
# this path runs once per survivor; synthetic nodes unparse as before.
|
|
177
255
|
def display_unparse(node)
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
256
|
+
return node.children.first.inspect if str_node?(node)
|
|
257
|
+
|
|
258
|
+
source_slice(node) || safe_unparse(node)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def str_node?(node)
|
|
262
|
+
node.respond_to?(:type) && node.respond_to?(:children) && node.type == :str
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Single-line original source of a parsed node; nil for synthetic nodes
|
|
266
|
+
# or multi-line slices (those fall back to normalized unparsing).
|
|
267
|
+
def source_slice(node)
|
|
268
|
+
return nil unless node.respond_to?(:location)
|
|
269
|
+
|
|
270
|
+
source = node.location&.expression&.source
|
|
271
|
+
source unless source.nil? || source.include?("\n")
|
|
272
|
+
rescue StandardError
|
|
273
|
+
nil
|
|
183
274
|
end
|
|
184
275
|
|
|
185
276
|
def score_line(result)
|
|
@@ -226,7 +317,7 @@ module Henitai
|
|
|
226
317
|
end
|
|
227
318
|
|
|
228
319
|
def colorize(text, color)
|
|
229
|
-
return text
|
|
320
|
+
return text unless color_enabled
|
|
230
321
|
|
|
231
322
|
"\e[#{color}m#{text}\e[0m"
|
|
232
323
|
end
|
|
@@ -250,7 +341,7 @@ module Henitai
|
|
|
250
341
|
class Json < Base
|
|
251
342
|
def report(result)
|
|
252
343
|
schema = result.to_stryker_schema
|
|
253
|
-
write_canonical(schema)
|
|
344
|
+
write_canonical(schema, authoritative: authoritative?(result))
|
|
254
345
|
write_session_snapshot(schema)
|
|
255
346
|
write_activation_recipes(result)
|
|
256
347
|
write_history_report
|
|
@@ -258,9 +349,8 @@ module Henitai
|
|
|
258
349
|
|
|
259
350
|
private
|
|
260
351
|
|
|
261
|
-
def write_canonical(schema)
|
|
262
|
-
|
|
263
|
-
File.write(canonical_path, JSON.pretty_generate(schema))
|
|
352
|
+
def write_canonical(schema, authoritative:)
|
|
353
|
+
CanonicalReportWriter.write(schema, path: canonical_path, authoritative:)
|
|
264
354
|
end
|
|
265
355
|
|
|
266
356
|
def write_session_snapshot(schema)
|
|
@@ -299,10 +389,6 @@ module Henitai
|
|
|
299
389
|
File.join(config.reports_dir, "sessions", session_id, SurvivorActivationCache::FILENAME)
|
|
300
390
|
end
|
|
301
391
|
|
|
302
|
-
def canonical_path
|
|
303
|
-
File.join(config.reports_dir, "mutation-report.json")
|
|
304
|
-
end
|
|
305
|
-
|
|
306
392
|
def write_history_report
|
|
307
393
|
store = history_store || default_history_store
|
|
308
394
|
return unless File.exist?(store.path)
|
|
@@ -325,8 +411,12 @@ module Henitai
|
|
|
325
411
|
# HTML reporter.
|
|
326
412
|
class Html < Base
|
|
327
413
|
def report(result)
|
|
414
|
+
report_schema(schema_for(result))
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def report_schema(schema)
|
|
328
418
|
FileUtils.mkdir_p(File.dirname(report_path))
|
|
329
|
-
File.write(report_path, html_document(
|
|
419
|
+
File.write(report_path, html_document(schema))
|
|
330
420
|
end
|
|
331
421
|
|
|
332
422
|
private
|
|
@@ -335,7 +425,7 @@ module Henitai
|
|
|
335
425
|
File.join(config.reports_dir, "mutation-report.html")
|
|
336
426
|
end
|
|
337
427
|
|
|
338
|
-
def html_document(
|
|
428
|
+
def html_document(schema)
|
|
339
429
|
<<~HTML
|
|
340
430
|
<!DOCTYPE html>
|
|
341
431
|
<html lang="en">
|
|
@@ -347,7 +437,7 @@ module Henitai
|
|
|
347
437
|
<body>
|
|
348
438
|
<mutation-test-report-app titlePostfix="Henitai"></mutation-test-report-app>
|
|
349
439
|
<script src="https://www.unpkg.com/mutation-testing-elements"></script>
|
|
350
|
-
<script type="application/json" id="henitai-report-data">#{escaped_report_json(
|
|
440
|
+
<script type="application/json" id="henitai-report-data">#{escaped_report_json(schema)}</script>
|
|
351
441
|
<script>
|
|
352
442
|
const report = JSON.parse(
|
|
353
443
|
document.getElementById("henitai-report-data").textContent
|
|
@@ -359,19 +449,138 @@ module Henitai
|
|
|
359
449
|
HTML
|
|
360
450
|
end
|
|
361
451
|
|
|
362
|
-
def
|
|
363
|
-
|
|
452
|
+
def schema_for(result)
|
|
453
|
+
schema = result.to_stryker_schema
|
|
454
|
+
return schema if authoritative?(result)
|
|
455
|
+
|
|
456
|
+
CanonicalReportMerger.merge(schema, canonical_path, prune_missing: true)
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def escaped_report_json(schema)
|
|
460
|
+
JSON.pretty_generate(schema)
|
|
364
461
|
.gsub("&", "\\u0026")
|
|
365
462
|
.gsub("<", "\\u003c")
|
|
366
463
|
.gsub(">", "\\u003e")
|
|
367
464
|
end
|
|
368
465
|
end
|
|
369
466
|
|
|
370
|
-
#
|
|
467
|
+
# Dry-run listing: prints the post-filter mutant set (Gates 0–3) without
|
|
468
|
+
# any execution results. Used internally by the runner for `--dry-run`;
|
|
469
|
+
# not part of the `reporters:` configuration surface.
|
|
470
|
+
class DryRun < Base
|
|
471
|
+
def initialize(config:, history_store: nil, io: $stdout)
|
|
472
|
+
super(config:, history_store:)
|
|
473
|
+
@io = io
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
def report(result)
|
|
477
|
+
io.puts(listing_lines(result.mutants))
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
private
|
|
481
|
+
|
|
482
|
+
attr_reader :io
|
|
483
|
+
|
|
484
|
+
def listing_lines(mutants)
|
|
485
|
+
header = ["Dry run: #{mutants.size} mutants (no mutants executed)"]
|
|
486
|
+
return header + ["", summary_line(mutants)] if mutants.empty?
|
|
487
|
+
|
|
488
|
+
header + grouped_lines(mutants) + ["", summary_line(mutants)]
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
def grouped_lines(mutants)
|
|
492
|
+
mutants.group_by { |mutant| subject_label(mutant) }.flat_map do |label, subject_mutants|
|
|
493
|
+
["", label] + subject_mutants.map { |mutant| mutant_line(mutant) }
|
|
494
|
+
end
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
def subject_label(mutant)
|
|
498
|
+
subject = mutant.subject
|
|
499
|
+
return subject.expression if subject.respond_to?(:expression) && subject.expression
|
|
500
|
+
|
|
501
|
+
mutant.location.fetch(:file, "(unknown subject)")
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def mutant_line(mutant)
|
|
505
|
+
line = format(
|
|
506
|
+
" %<operator>s — %<description>s %<file>s:%<line>d [%<status>s]",
|
|
507
|
+
operator: mutant.operator,
|
|
508
|
+
description: mutant.description,
|
|
509
|
+
file: mutant.location.fetch(:file),
|
|
510
|
+
line: mutant.location.fetch(:start_line),
|
|
511
|
+
status: mutant.status
|
|
512
|
+
)
|
|
513
|
+
reason = ignore_reason_for(mutant)
|
|
514
|
+
reason ? "#{line} #{reason}" : line
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
def ignore_reason_for(mutant)
|
|
518
|
+
return nil unless mutant.respond_to?(:ignore_reason)
|
|
519
|
+
|
|
520
|
+
reason = mutant.ignore_reason
|
|
521
|
+
reason ? "(#{reason})" : nil
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
def summary_line(mutants)
|
|
525
|
+
counts = mutants.group_by(&:status).transform_values(&:size)
|
|
526
|
+
summary = counts.map { |status, count| "#{status} #{count}" }.join(" | ")
|
|
527
|
+
"Summary: #{summary.empty? ? 'no mutants' : summary}"
|
|
528
|
+
end
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
# GitHub Actions annotation reporter. Prints one `::warning` workflow
|
|
532
|
+
# command per survived mutant so survivors show up inline on the PR diff.
|
|
533
|
+
# Killed/ignored/no-coverage/timeout mutants stay silent.
|
|
534
|
+
class Github < Base
|
|
535
|
+
def initialize(config:, history_store: nil, io: $stdout)
|
|
536
|
+
super(config:, history_store:)
|
|
537
|
+
@io = io
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
def report(result)
|
|
541
|
+
result.mutants.select(&:survived?).each do |mutant|
|
|
542
|
+
io.puts(annotation_line(mutant))
|
|
543
|
+
end
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
private
|
|
547
|
+
|
|
548
|
+
attr_reader :io
|
|
549
|
+
|
|
550
|
+
def annotation_line(mutant)
|
|
551
|
+
format(
|
|
552
|
+
"::warning file=%<file>s,line=%<line>d::%<message>s",
|
|
553
|
+
file: relative_path(mutant.location.fetch(:file)),
|
|
554
|
+
line: mutant.location.fetch(:start_line),
|
|
555
|
+
message: escape_message("Survived mutant: #{mutant.operator} — #{mutant.description}")
|
|
556
|
+
)
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
def relative_path(file)
|
|
560
|
+
pathname = Pathname.new(file)
|
|
561
|
+
return file unless pathname.absolute?
|
|
562
|
+
|
|
563
|
+
pathname.relative_path_from(Dir.pwd).to_s
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
# GitHub workflow-command message payload escaping: only `%`, LF and CR
|
|
567
|
+
# are significant; everything else is parsed literally.
|
|
568
|
+
def escape_message(message)
|
|
569
|
+
message.gsub("%", "%25").gsub("\n", "%0A").gsub("\r", "%0D")
|
|
570
|
+
end
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
# Dashboard reporter. Delegates project/version/api-key resolution to
|
|
574
|
+
# {DashboardMetadataProvider} so it never touches real ENV or git itself.
|
|
371
575
|
class Dashboard < Base
|
|
372
576
|
DEFAULT_BASE_URL = "https://dashboard.stryker-mutator.io"
|
|
373
577
|
HTTP_TIMEOUT_SECONDS = 30
|
|
374
578
|
|
|
579
|
+
def initialize(config:, history_store: nil, metadata_provider: nil)
|
|
580
|
+
super(config:, history_store:)
|
|
581
|
+
@metadata_provider = metadata_provider || DashboardMetadataProvider.new(dashboard_config: config.dashboard)
|
|
582
|
+
end
|
|
583
|
+
|
|
375
584
|
def report(result)
|
|
376
585
|
return unless ready?
|
|
377
586
|
|
|
@@ -380,8 +589,20 @@ module Henitai
|
|
|
380
589
|
send_request(uri, request)
|
|
381
590
|
end
|
|
382
591
|
|
|
592
|
+
class << self
|
|
593
|
+
def project_from_git_url(url)
|
|
594
|
+
DashboardMetadataProvider.project_from_git_url(url)
|
|
595
|
+
end
|
|
596
|
+
end
|
|
597
|
+
|
|
383
598
|
private
|
|
384
599
|
|
|
600
|
+
attr_reader :metadata_provider
|
|
601
|
+
|
|
602
|
+
def project = metadata_provider.project
|
|
603
|
+
def version = metadata_provider.version
|
|
604
|
+
def api_key = metadata_provider.api_key
|
|
605
|
+
|
|
385
606
|
def ready?
|
|
386
607
|
!project.nil? && !version.nil? && !api_key.nil?
|
|
387
608
|
end
|
|
@@ -413,7 +634,7 @@ module Henitai
|
|
|
413
634
|
def dashboard_uri
|
|
414
635
|
uri = URI.parse(base_url)
|
|
415
636
|
# @type var segments: Array[String]
|
|
416
|
-
base_path = uri.path.to_s.chomp("/")
|
|
637
|
+
base_path = uri.path.to_s.chomp("/").delete_prefix("/")
|
|
417
638
|
segments = []
|
|
418
639
|
segments << base_path unless base_path.empty?
|
|
419
640
|
segments += ["api", "reports", project_path, encoded_version]
|
|
@@ -427,38 +648,6 @@ module Henitai
|
|
|
427
648
|
config.dashboard[:base_url] || DEFAULT_BASE_URL
|
|
428
649
|
end
|
|
429
650
|
|
|
430
|
-
def project
|
|
431
|
-
@project ||= config.dashboard[:project] || project_from_git_remote
|
|
432
|
-
end
|
|
433
|
-
|
|
434
|
-
def version
|
|
435
|
-
@version ||= env_version || git_branch_name
|
|
436
|
-
end
|
|
437
|
-
|
|
438
|
-
def env_version
|
|
439
|
-
ref_name = ENV.fetch("GITHUB_REF_NAME", nil)
|
|
440
|
-
return ref_name unless blank?(ref_name)
|
|
441
|
-
|
|
442
|
-
ref = ENV.fetch("GITHUB_REF", nil)
|
|
443
|
-
return ref_without_prefix(ref) unless ref.nil? || blank?(ref)
|
|
444
|
-
|
|
445
|
-
ENV.fetch("GITHUB_SHA", nil)
|
|
446
|
-
end
|
|
447
|
-
|
|
448
|
-
def ref_without_prefix(ref)
|
|
449
|
-
return nil if blank?(ref)
|
|
450
|
-
|
|
451
|
-
ref.to_s.sub(%r{^refs/(heads|tags|pull)/}, "")
|
|
452
|
-
end
|
|
453
|
-
|
|
454
|
-
def project_from_git_remote
|
|
455
|
-
self.class.project_from_git_url(git_remote_url)
|
|
456
|
-
end
|
|
457
|
-
|
|
458
|
-
def api_key
|
|
459
|
-
ENV.fetch("STRYKER_DASHBOARD_API_KEY", nil)
|
|
460
|
-
end
|
|
461
|
-
|
|
462
651
|
def project_path
|
|
463
652
|
project.to_s.split("/").map { |segment| URI.encode_www_form_component(segment) }.join("/")
|
|
464
653
|
end
|
|
@@ -466,64 +655,6 @@ module Henitai
|
|
|
466
655
|
def encoded_version
|
|
467
656
|
URI.encode_www_form_component(version.to_s)
|
|
468
657
|
end
|
|
469
|
-
|
|
470
|
-
def blank?(value)
|
|
471
|
-
value.nil? || value.strip.empty?
|
|
472
|
-
end
|
|
473
|
-
|
|
474
|
-
def git_remote_url
|
|
475
|
-
stdout, status = Open3.capture2("git", "remote", "get-url", "origin")
|
|
476
|
-
return stdout.strip if status.success?
|
|
477
|
-
|
|
478
|
-
nil
|
|
479
|
-
rescue Errno::ENOENT
|
|
480
|
-
nil
|
|
481
|
-
end
|
|
482
|
-
|
|
483
|
-
def git_branch_name
|
|
484
|
-
stdout, status = Open3.capture2("git", "rev-parse", "--abbrev-ref", "HEAD")
|
|
485
|
-
return stdout.strip if status.success? && !stdout.strip.empty?
|
|
486
|
-
|
|
487
|
-
nil
|
|
488
|
-
rescue Errno::ENOENT
|
|
489
|
-
nil
|
|
490
|
-
end
|
|
491
|
-
|
|
492
|
-
class << self
|
|
493
|
-
def project_from_git_url(url)
|
|
494
|
-
normalized = normalize_git_url(url)
|
|
495
|
-
return nil if normalized.nil?
|
|
496
|
-
|
|
497
|
-
return project_from_uri_url(normalized) if normalized.include?("://")
|
|
498
|
-
return project_from_ssh_url(normalized) if normalized.include?("@")
|
|
499
|
-
|
|
500
|
-
normalized
|
|
501
|
-
rescue URI::InvalidURIError
|
|
502
|
-
nil
|
|
503
|
-
end
|
|
504
|
-
|
|
505
|
-
def normalize_git_url(url)
|
|
506
|
-
return nil if url.nil? || url.strip.empty?
|
|
507
|
-
|
|
508
|
-
url.strip.sub(/\.git\z/, "")
|
|
509
|
-
end
|
|
510
|
-
|
|
511
|
-
def project_from_uri_url(normalized)
|
|
512
|
-
uri = URI.parse(normalized)
|
|
513
|
-
path = uri.path.to_s.sub(%r{^/}, "")
|
|
514
|
-
[uri.host, path].compact.reject(&:empty?).join("/")
|
|
515
|
-
end
|
|
516
|
-
|
|
517
|
-
def project_from_ssh_url(normalized)
|
|
518
|
-
_, host_and_path = normalized.split("@", 2)
|
|
519
|
-
return nil if host_and_path.nil?
|
|
520
|
-
|
|
521
|
-
host, path = host_and_path.split(":", 2)
|
|
522
|
-
return nil unless host && path
|
|
523
|
-
|
|
524
|
-
"#{host}/#{path}"
|
|
525
|
-
end
|
|
526
|
-
end
|
|
527
658
|
end
|
|
528
659
|
end
|
|
529
660
|
end
|