henitai 0.2.1 → 0.3.0
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 +80 -1
- data/README.md +123 -2
- 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 +64 -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 +66 -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 +238 -117
- data/lib/henitai/reports_directory_lock.rb +76 -0
- data/lib/henitai/result.rb +72 -9
- data/lib/henitai/runner.rb +82 -40
- 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 +230 -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,42 @@ 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
|
+
].compact
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def reused_verdicts_line(result)
|
|
145
|
+
reused = reused_mutants(result)
|
|
146
|
+
return nil if reused.empty?
|
|
147
|
+
|
|
148
|
+
format(
|
|
149
|
+
"%<reused>d of %<total>d verdicts reused from history (%<killed>d killed, %<survived>d survived)",
|
|
150
|
+
reused: reused.size,
|
|
151
|
+
total: result.mutants.size,
|
|
152
|
+
killed: reused.count { |mutant| mutant.status == :killed },
|
|
153
|
+
survived: reused.count { |mutant| mutant.status == :survived }
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def reused_mutants(result)
|
|
158
|
+
result.mutants.select { |mutant| mutant.respond_to?(:from_cache?) && mutant.from_cache? }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Cached verdicts make the combined MS/MSI partly synthetic; the
|
|
162
|
+
# executed-only pair keeps this run's own signal visible.
|
|
163
|
+
def executed_only_score_line(result)
|
|
164
|
+
return nil unless result.respond_to?(:executed_scoring_summary)
|
|
165
|
+
|
|
166
|
+
summary = result.executed_scoring_summary # : Hash[Symbol, untyped]?
|
|
167
|
+
return nil unless summary
|
|
168
|
+
|
|
169
|
+
format(
|
|
170
|
+
"Executed-only MS %<score>s | MSI %<indicator>s",
|
|
171
|
+
score: format_percent(summary[:mutation_score]),
|
|
172
|
+
indicator: format_percent(summary[:mutation_score_indicator])
|
|
173
|
+
)
|
|
117
174
|
end
|
|
118
175
|
|
|
119
176
|
def partial_summary_lines(result)
|
|
@@ -167,19 +224,43 @@ module Henitai
|
|
|
167
224
|
end
|
|
168
225
|
|
|
169
226
|
def mutated_line(mutant)
|
|
170
|
-
format("+ %s",
|
|
227
|
+
format("+ %s", display_mutated_source(mutant))
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Prefer the mutant's memoized unparse (shared with the JSON reporter);
|
|
231
|
+
# string literals keep the visible-escape rendering below.
|
|
232
|
+
def display_mutated_source(mutant)
|
|
233
|
+
node = mutant.mutated_node
|
|
234
|
+
return node.children.first.inspect if str_node?(node)
|
|
235
|
+
return mutant.mutated_source if mutant.respond_to?(:mutated_source)
|
|
236
|
+
|
|
237
|
+
display_unparse(node)
|
|
171
238
|
end
|
|
172
239
|
|
|
173
240
|
# Like safe_unparse but makes invisible characters visible in terminal
|
|
174
241
|
# output. For string literal nodes the inner value is shown via #inspect
|
|
175
|
-
# so that e.g. "" vs " " vs "\n" are unambiguous.
|
|
176
|
-
#
|
|
242
|
+
# so that e.g. "" vs " " vs "\n" are unambiguous. Nodes parsed from real
|
|
243
|
+
# source render their original source slice — unparsing is expensive and
|
|
244
|
+
# this path runs once per survivor; synthetic nodes unparse as before.
|
|
177
245
|
def display_unparse(node)
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
246
|
+
return node.children.first.inspect if str_node?(node)
|
|
247
|
+
|
|
248
|
+
source_slice(node) || safe_unparse(node)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def str_node?(node)
|
|
252
|
+
node.respond_to?(:type) && node.respond_to?(:children) && node.type == :str
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Single-line original source of a parsed node; nil for synthetic nodes
|
|
256
|
+
# or multi-line slices (those fall back to normalized unparsing).
|
|
257
|
+
def source_slice(node)
|
|
258
|
+
return nil unless node.respond_to?(:location)
|
|
259
|
+
|
|
260
|
+
source = node.location&.expression&.source
|
|
261
|
+
source unless source.nil? || source.include?("\n")
|
|
262
|
+
rescue StandardError
|
|
263
|
+
nil
|
|
183
264
|
end
|
|
184
265
|
|
|
185
266
|
def score_line(result)
|
|
@@ -226,7 +307,7 @@ module Henitai
|
|
|
226
307
|
end
|
|
227
308
|
|
|
228
309
|
def colorize(text, color)
|
|
229
|
-
return text
|
|
310
|
+
return text unless color_enabled
|
|
230
311
|
|
|
231
312
|
"\e[#{color}m#{text}\e[0m"
|
|
232
313
|
end
|
|
@@ -250,7 +331,7 @@ module Henitai
|
|
|
250
331
|
class Json < Base
|
|
251
332
|
def report(result)
|
|
252
333
|
schema = result.to_stryker_schema
|
|
253
|
-
write_canonical(schema)
|
|
334
|
+
write_canonical(schema, authoritative: authoritative?(result))
|
|
254
335
|
write_session_snapshot(schema)
|
|
255
336
|
write_activation_recipes(result)
|
|
256
337
|
write_history_report
|
|
@@ -258,9 +339,8 @@ module Henitai
|
|
|
258
339
|
|
|
259
340
|
private
|
|
260
341
|
|
|
261
|
-
def write_canonical(schema)
|
|
262
|
-
|
|
263
|
-
File.write(canonical_path, JSON.pretty_generate(schema))
|
|
342
|
+
def write_canonical(schema, authoritative:)
|
|
343
|
+
CanonicalReportWriter.write(schema, path: canonical_path, authoritative:)
|
|
264
344
|
end
|
|
265
345
|
|
|
266
346
|
def write_session_snapshot(schema)
|
|
@@ -299,10 +379,6 @@ module Henitai
|
|
|
299
379
|
File.join(config.reports_dir, "sessions", session_id, SurvivorActivationCache::FILENAME)
|
|
300
380
|
end
|
|
301
381
|
|
|
302
|
-
def canonical_path
|
|
303
|
-
File.join(config.reports_dir, "mutation-report.json")
|
|
304
|
-
end
|
|
305
|
-
|
|
306
382
|
def write_history_report
|
|
307
383
|
store = history_store || default_history_store
|
|
308
384
|
return unless File.exist?(store.path)
|
|
@@ -325,8 +401,12 @@ module Henitai
|
|
|
325
401
|
# HTML reporter.
|
|
326
402
|
class Html < Base
|
|
327
403
|
def report(result)
|
|
404
|
+
report_schema(schema_for(result))
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def report_schema(schema)
|
|
328
408
|
FileUtils.mkdir_p(File.dirname(report_path))
|
|
329
|
-
File.write(report_path, html_document(
|
|
409
|
+
File.write(report_path, html_document(schema))
|
|
330
410
|
end
|
|
331
411
|
|
|
332
412
|
private
|
|
@@ -335,7 +415,7 @@ module Henitai
|
|
|
335
415
|
File.join(config.reports_dir, "mutation-report.html")
|
|
336
416
|
end
|
|
337
417
|
|
|
338
|
-
def html_document(
|
|
418
|
+
def html_document(schema)
|
|
339
419
|
<<~HTML
|
|
340
420
|
<!DOCTYPE html>
|
|
341
421
|
<html lang="en">
|
|
@@ -347,7 +427,7 @@ module Henitai
|
|
|
347
427
|
<body>
|
|
348
428
|
<mutation-test-report-app titlePostfix="Henitai"></mutation-test-report-app>
|
|
349
429
|
<script src="https://www.unpkg.com/mutation-testing-elements"></script>
|
|
350
|
-
<script type="application/json" id="henitai-report-data">#{escaped_report_json(
|
|
430
|
+
<script type="application/json" id="henitai-report-data">#{escaped_report_json(schema)}</script>
|
|
351
431
|
<script>
|
|
352
432
|
const report = JSON.parse(
|
|
353
433
|
document.getElementById("henitai-report-data").textContent
|
|
@@ -359,19 +439,138 @@ module Henitai
|
|
|
359
439
|
HTML
|
|
360
440
|
end
|
|
361
441
|
|
|
362
|
-
def
|
|
363
|
-
|
|
442
|
+
def schema_for(result)
|
|
443
|
+
schema = result.to_stryker_schema
|
|
444
|
+
return schema if authoritative?(result)
|
|
445
|
+
|
|
446
|
+
CanonicalReportMerger.merge(schema, canonical_path, prune_missing: true)
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def escaped_report_json(schema)
|
|
450
|
+
JSON.pretty_generate(schema)
|
|
364
451
|
.gsub("&", "\\u0026")
|
|
365
452
|
.gsub("<", "\\u003c")
|
|
366
453
|
.gsub(">", "\\u003e")
|
|
367
454
|
end
|
|
368
455
|
end
|
|
369
456
|
|
|
370
|
-
#
|
|
457
|
+
# Dry-run listing: prints the post-filter mutant set (Gates 0–3) without
|
|
458
|
+
# any execution results. Used internally by the runner for `--dry-run`;
|
|
459
|
+
# not part of the `reporters:` configuration surface.
|
|
460
|
+
class DryRun < Base
|
|
461
|
+
def initialize(config:, history_store: nil, io: $stdout)
|
|
462
|
+
super(config:, history_store:)
|
|
463
|
+
@io = io
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def report(result)
|
|
467
|
+
io.puts(listing_lines(result.mutants))
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
private
|
|
471
|
+
|
|
472
|
+
attr_reader :io
|
|
473
|
+
|
|
474
|
+
def listing_lines(mutants)
|
|
475
|
+
header = ["Dry run: #{mutants.size} mutants (no mutants executed)"]
|
|
476
|
+
return header + ["", summary_line(mutants)] if mutants.empty?
|
|
477
|
+
|
|
478
|
+
header + grouped_lines(mutants) + ["", summary_line(mutants)]
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def grouped_lines(mutants)
|
|
482
|
+
mutants.group_by { |mutant| subject_label(mutant) }.flat_map do |label, subject_mutants|
|
|
483
|
+
["", label] + subject_mutants.map { |mutant| mutant_line(mutant) }
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
def subject_label(mutant)
|
|
488
|
+
subject = mutant.subject
|
|
489
|
+
return subject.expression if subject.respond_to?(:expression) && subject.expression
|
|
490
|
+
|
|
491
|
+
mutant.location.fetch(:file, "(unknown subject)")
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def mutant_line(mutant)
|
|
495
|
+
line = format(
|
|
496
|
+
" %<operator>s — %<description>s %<file>s:%<line>d [%<status>s]",
|
|
497
|
+
operator: mutant.operator,
|
|
498
|
+
description: mutant.description,
|
|
499
|
+
file: mutant.location.fetch(:file),
|
|
500
|
+
line: mutant.location.fetch(:start_line),
|
|
501
|
+
status: mutant.status
|
|
502
|
+
)
|
|
503
|
+
reason = ignore_reason_for(mutant)
|
|
504
|
+
reason ? "#{line} #{reason}" : line
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def ignore_reason_for(mutant)
|
|
508
|
+
return nil unless mutant.respond_to?(:ignore_reason)
|
|
509
|
+
|
|
510
|
+
reason = mutant.ignore_reason
|
|
511
|
+
reason ? "(#{reason})" : nil
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def summary_line(mutants)
|
|
515
|
+
counts = mutants.group_by(&:status).transform_values(&:size)
|
|
516
|
+
summary = counts.map { |status, count| "#{status} #{count}" }.join(" | ")
|
|
517
|
+
"Summary: #{summary.empty? ? 'no mutants' : summary}"
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# GitHub Actions annotation reporter. Prints one `::warning` workflow
|
|
522
|
+
# command per survived mutant so survivors show up inline on the PR diff.
|
|
523
|
+
# Killed/ignored/no-coverage/timeout mutants stay silent.
|
|
524
|
+
class Github < Base
|
|
525
|
+
def initialize(config:, history_store: nil, io: $stdout)
|
|
526
|
+
super(config:, history_store:)
|
|
527
|
+
@io = io
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def report(result)
|
|
531
|
+
result.mutants.select(&:survived?).each do |mutant|
|
|
532
|
+
io.puts(annotation_line(mutant))
|
|
533
|
+
end
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
private
|
|
537
|
+
|
|
538
|
+
attr_reader :io
|
|
539
|
+
|
|
540
|
+
def annotation_line(mutant)
|
|
541
|
+
format(
|
|
542
|
+
"::warning file=%<file>s,line=%<line>d::%<message>s",
|
|
543
|
+
file: relative_path(mutant.location.fetch(:file)),
|
|
544
|
+
line: mutant.location.fetch(:start_line),
|
|
545
|
+
message: escape_message("Survived mutant: #{mutant.operator} — #{mutant.description}")
|
|
546
|
+
)
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
def relative_path(file)
|
|
550
|
+
pathname = Pathname.new(file)
|
|
551
|
+
return file unless pathname.absolute?
|
|
552
|
+
|
|
553
|
+
pathname.relative_path_from(Dir.pwd).to_s
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
# GitHub workflow-command message payload escaping: only `%`, LF and CR
|
|
557
|
+
# are significant; everything else is parsed literally.
|
|
558
|
+
def escape_message(message)
|
|
559
|
+
message.gsub("%", "%25").gsub("\n", "%0A").gsub("\r", "%0D")
|
|
560
|
+
end
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
# Dashboard reporter. Delegates project/version/api-key resolution to
|
|
564
|
+
# {DashboardMetadataProvider} so it never touches real ENV or git itself.
|
|
371
565
|
class Dashboard < Base
|
|
372
566
|
DEFAULT_BASE_URL = "https://dashboard.stryker-mutator.io"
|
|
373
567
|
HTTP_TIMEOUT_SECONDS = 30
|
|
374
568
|
|
|
569
|
+
def initialize(config:, history_store: nil, metadata_provider: nil)
|
|
570
|
+
super(config:, history_store:)
|
|
571
|
+
@metadata_provider = metadata_provider || DashboardMetadataProvider.new(dashboard_config: config.dashboard)
|
|
572
|
+
end
|
|
573
|
+
|
|
375
574
|
def report(result)
|
|
376
575
|
return unless ready?
|
|
377
576
|
|
|
@@ -380,8 +579,20 @@ module Henitai
|
|
|
380
579
|
send_request(uri, request)
|
|
381
580
|
end
|
|
382
581
|
|
|
582
|
+
class << self
|
|
583
|
+
def project_from_git_url(url)
|
|
584
|
+
DashboardMetadataProvider.project_from_git_url(url)
|
|
585
|
+
end
|
|
586
|
+
end
|
|
587
|
+
|
|
383
588
|
private
|
|
384
589
|
|
|
590
|
+
attr_reader :metadata_provider
|
|
591
|
+
|
|
592
|
+
def project = metadata_provider.project
|
|
593
|
+
def version = metadata_provider.version
|
|
594
|
+
def api_key = metadata_provider.api_key
|
|
595
|
+
|
|
385
596
|
def ready?
|
|
386
597
|
!project.nil? && !version.nil? && !api_key.nil?
|
|
387
598
|
end
|
|
@@ -413,7 +624,7 @@ module Henitai
|
|
|
413
624
|
def dashboard_uri
|
|
414
625
|
uri = URI.parse(base_url)
|
|
415
626
|
# @type var segments: Array[String]
|
|
416
|
-
base_path = uri.path.to_s.chomp("/")
|
|
627
|
+
base_path = uri.path.to_s.chomp("/").delete_prefix("/")
|
|
417
628
|
segments = []
|
|
418
629
|
segments << base_path unless base_path.empty?
|
|
419
630
|
segments += ["api", "reports", project_path, encoded_version]
|
|
@@ -427,38 +638,6 @@ module Henitai
|
|
|
427
638
|
config.dashboard[:base_url] || DEFAULT_BASE_URL
|
|
428
639
|
end
|
|
429
640
|
|
|
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
641
|
def project_path
|
|
463
642
|
project.to_s.split("/").map { |segment| URI.encode_www_form_component(segment) }.join("/")
|
|
464
643
|
end
|
|
@@ -466,64 +645,6 @@ module Henitai
|
|
|
466
645
|
def encoded_version
|
|
467
646
|
URI.encode_www_form_component(version.to_s)
|
|
468
647
|
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
648
|
end
|
|
528
649
|
end
|
|
529
650
|
end
|