simplecov 0.22.0 → 1.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/LICENSE +1 -1
- data/README.md +173 -875
- data/exe/simplecov +8 -0
- data/lib/minitest/simplecov_plugin.rb +18 -7
- data/lib/simplecov/atomic_file.rb +67 -0
- data/lib/simplecov/autostart.rb +9 -0
- data/lib/simplecov/baseline/parser.rb +58 -0
- data/lib/simplecov/baseline.rb +170 -0
- data/lib/simplecov/cli/affected/changed_files.rb +57 -0
- data/lib/simplecov/cli/affected/selection.rb +117 -0
- data/lib/simplecov/cli/affected.rb +148 -0
- data/lib/simplecov/cli/annotations.rb +166 -0
- data/lib/simplecov/cli/badge/svg.rb +70 -0
- data/lib/simplecov/cli/badge.rb +62 -0
- data/lib/simplecov/cli/clean.rb +86 -0
- data/lib/simplecov/cli/command_helpers.rb +91 -0
- data/lib/simplecov/cli/completions/scripts.rb +114 -0
- data/lib/simplecov/cli/completions.rb +83 -0
- data/lib/simplecov/cli/coverage.rb +83 -0
- data/lib/simplecov/cli/coverage_file.rb +109 -0
- data/lib/simplecov/cli/dead_code/output.rb +100 -0
- data/lib/simplecov/cli/dead_code.rb +139 -0
- data/lib/simplecov/cli/diff/output.rb +45 -0
- data/lib/simplecov/cli/diff.rb +115 -0
- data/lib/simplecov/cli/dotfile.rb +156 -0
- data/lib/simplecov/cli/git.rb +41 -0
- data/lib/simplecov/cli/history/output.rb +135 -0
- data/lib/simplecov/cli/history.rb +67 -0
- data/lib/simplecov/cli/merge.rb +115 -0
- data/lib/simplecov/cli/open.rb +45 -0
- data/lib/simplecov/cli/patch/changed_lines.rb +149 -0
- data/lib/simplecov/cli/patch/output.rb +151 -0
- data/lib/simplecov/cli/patch.rb +175 -0
- data/lib/simplecov/cli/ratchet/output.rb +44 -0
- data/lib/simplecov/cli/ratchet.rb +83 -0
- data/lib/simplecov/cli/report.rb +91 -0
- data/lib/simplecov/cli/run.rb +35 -0
- data/lib/simplecov/cli/serve/report_preparer.rb +30 -0
- data/lib/simplecov/cli/serve/static_file_handler.rb +128 -0
- data/lib/simplecov/cli/serve.rb +88 -0
- data/lib/simplecov/cli/show/annotator.rb +87 -0
- data/lib/simplecov/cli/show/sweep.rb +36 -0
- data/lib/simplecov/cli/show.rb +133 -0
- data/lib/simplecov/cli/status/facts.rb +87 -0
- data/lib/simplecov/cli/status.rb +93 -0
- data/lib/simplecov/cli/tests/redundancy.rb +78 -0
- data/lib/simplecov/cli/tests.rb +148 -0
- data/lib/simplecov/cli/uncovered/misses.rb +33 -0
- data/lib/simplecov/cli/uncovered.rb +127 -0
- data/lib/simplecov/cli/usage.rb +153 -0
- data/lib/simplecov/cli/watch/live_report.rb +73 -0
- data/lib/simplecov/cli/watch/narrator.rb +55 -0
- data/lib/simplecov/cli/watch/poller.rb +45 -0
- data/lib/simplecov/cli/watch/session.rb +134 -0
- data/lib/simplecov/cli/watch/test_plan.rb +43 -0
- data/lib/simplecov/cli/watch.rb +85 -0
- data/lib/simplecov/cli.rb +114 -0
- data/lib/simplecov/color.rb +65 -0
- data/lib/simplecov/combine/branches_combiner.rb +44 -21
- data/lib/simplecov/combine/coverage_accumulator.rb +175 -0
- data/lib/simplecov/combine/identity_interner.rb +27 -0
- data/lib/simplecov/combine/interned_counts.rb +28 -0
- data/lib/simplecov/combine/lines_combiner.rb +35 -29
- data/lib/simplecov/combine/methods_combiner.rb +48 -0
- data/lib/simplecov/combine/results_combiner.rb +9 -49
- data/lib/simplecov/combine.rb +0 -24
- data/lib/simplecov/command_guesser.rb +99 -42
- data/lib/simplecov/configuration/baseline.rb +31 -0
- data/lib/simplecov/configuration/coverage.rb +184 -0
- data/lib/simplecov/configuration/coverage_criteria.rb +143 -0
- data/lib/simplecov/configuration/deprecations.rb +21 -0
- data/lib/simplecov/configuration/eval_coverage.rb +37 -0
- data/lib/simplecov/configuration/filters.rb +131 -0
- data/lib/simplecov/configuration/formatting.rb +129 -0
- data/lib/simplecov/configuration/groups.rb +48 -0
- data/lib/simplecov/configuration/history.rb +44 -0
- data/lib/simplecov/configuration/ignored_entries.rb +72 -0
- data/lib/simplecov/configuration/merging.rb +197 -0
- data/lib/simplecov/configuration/missed_caps.rb +51 -0
- data/lib/simplecov/configuration/production.rb +23 -0
- data/lib/simplecov/configuration/test_tracking.rb +48 -0
- data/lib/simplecov/configuration/thresholds.rb +150 -0
- data/lib/simplecov/configuration/view_coverage.rb +37 -0
- data/lib/simplecov/configuration.rb +100 -443
- data/lib/simplecov/configuration_error.rb +5 -0
- data/lib/simplecov/context_map/union.rb +81 -0
- data/lib/simplecov/context_map.rb +187 -0
- data/lib/simplecov/coverage_json.rb +21 -0
- data/lib/simplecov/coverage_statistics.rb +24 -28
- data/lib/simplecov/coverage_violations.rb +254 -0
- data/lib/simplecov/current_run.rb +61 -0
- data/lib/simplecov/defaults.rb +20 -28
- data/lib/simplecov/deprecation.rb +63 -0
- data/lib/simplecov/directive/erb.rb +44 -0
- data/lib/simplecov/directive/haml.rb +33 -0
- data/lib/simplecov/directive/indented_template.rb +37 -0
- data/lib/simplecov/directive/slim.rb +35 -0
- data/lib/simplecov/directive/template.rb +29 -0
- data/lib/simplecov/directive.rb +152 -0
- data/lib/simplecov/exit_codes/baseline_check.rb +46 -0
- data/lib/simplecov/exit_codes/check.rb +39 -0
- data/lib/simplecov/exit_codes/exit_code_handling.rb +24 -5
- data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +17 -67
- data/lib/simplecov/exit_codes/maximum_missed_check.rb +26 -0
- data/lib/simplecov/exit_codes/maximum_missed_per_file_check.rb +34 -0
- data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +27 -0
- data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +20 -38
- data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +28 -0
- data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +37 -34
- data/lib/simplecov/exit_codes.rb +26 -0
- data/lib/simplecov/exit_handling.rb +163 -0
- data/lib/simplecov/file_list.rb +63 -48
- data/lib/simplecov/filter.rb +98 -42
- data/lib/simplecov/formatter/base.rb +100 -0
- data/lib/simplecov/formatter/baseline_formatter.rb +98 -0
- data/lib/simplecov/formatter/coverage_json_writer.rb +95 -0
- data/lib/simplecov/formatter/html_formatter/public/index.html +93 -0
- data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +153 -0
- data/lib/simplecov/formatter/html_formatter.rb +88 -0
- data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +122 -0
- data/lib/simplecov/formatter/json_formatter/production_section_formatter.rb +48 -0
- data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +132 -0
- data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +97 -0
- data/lib/simplecov/formatter/json_formatter.rb +40 -0
- data/lib/simplecov/formatter/multi_formatter.rb +8 -10
- data/lib/simplecov/formatter/simple_formatter.rb +12 -15
- data/lib/simplecov/formatter.rb +21 -1
- data/lib/simplecov/group_names.rb +33 -0
- data/lib/simplecov/history.rb +115 -0
- data/lib/simplecov/last_run.rb +16 -5
- data/lib/simplecov/lines_classifier.rb +67 -19
- data/lib/simplecov/load_global_config.rb +12 -4
- data/lib/simplecov/parallel_adapters/base.rb +50 -0
- data/lib/simplecov/parallel_adapters/generic.rb +33 -0
- data/lib/simplecov/parallel_adapters/parallel_tests.rb +72 -0
- data/lib/simplecov/parallel_adapters.rb +54 -0
- data/lib/simplecov/parallel_coordination.rb +124 -0
- data/lib/simplecov/parallel_result_merger/worker_payload.rb +33 -0
- data/lib/simplecov/parallel_result_merger.rb +171 -0
- data/lib/simplecov/process.rb +34 -12
- data/lib/simplecov/production/error.rb +11 -0
- data/lib/simplecov/production/file_sink.rb +130 -0
- data/lib/simplecov/production.rb +249 -0
- data/lib/simplecov/profiles/bundler_filter.rb +1 -1
- data/lib/simplecov/profiles/hidden_filter.rb +1 -1
- data/lib/simplecov/profiles/rails.rb +13 -10
- data/lib/simplecov/profiles/root_filter.rb +2 -5
- data/lib/simplecov/profiles/strict.rb +22 -0
- data/lib/simplecov/profiles/test_frameworks.rb +1 -4
- data/lib/simplecov/profiles.rb +26 -19
- data/lib/simplecov/report_deferral.rb +45 -0
- data/lib/simplecov/report_stamp.rb +25 -0
- data/lib/simplecov/result/filter_config.rb +19 -0
- data/lib/simplecov/result/missing_source_files_reporter.rb +48 -0
- data/lib/simplecov/result/serialization.rb +43 -0
- data/lib/simplecov/result/source_file_builder.rb +46 -0
- data/lib/simplecov/result.rb +123 -47
- data/lib/simplecov/result_adapter.rb +130 -11
- data/lib/simplecov/result_merger/contexts.rb +22 -0
- data/lib/simplecov/result_merger/legacy_format_adapter.rb +24 -0
- data/lib/simplecov/result_merger/resultset_file.rb +67 -0
- data/lib/simplecov/result_merger/resultset_run_identity.rb +65 -0
- data/lib/simplecov/result_merger/resultset_store.rb +62 -0
- data/lib/simplecov/result_merger/unloaded_files.rb +95 -0
- data/lib/simplecov/result_merger.rb +120 -124
- data/lib/simplecov/result_processing.rb +188 -0
- data/lib/simplecov/run_identity.rb +75 -0
- data/lib/simplecov/simulate_coverage.rb +72 -17
- data/lib/simplecov/source_file/branch.rb +8 -35
- data/lib/simplecov/source_file/branch_builder.rb +95 -0
- data/lib/simplecov/source_file/builder_context.rb +28 -0
- data/lib/simplecov/source_file/line.rb +17 -32
- data/lib/simplecov/source_file/line_builder.rb +37 -0
- data/lib/simplecov/source_file/method.rb +54 -0
- data/lib/simplecov/source_file/method_builder.rb +51 -0
- data/lib/simplecov/source_file/ruby_data_parser.rb +103 -0
- data/lib/simplecov/source_file/skip_chunks.rb +72 -0
- data/lib/simplecov/source_file/source_loader.rb +89 -0
- data/lib/simplecov/source_file/statistics.rb +76 -0
- data/lib/simplecov/source_file.rb +64 -260
- data/lib/simplecov/static_coverage_extractor/condition_folding.rb +155 -0
- data/lib/simplecov/static_coverage_extractor/location_conventions.rb +191 -0
- data/lib/simplecov/static_coverage_extractor/method_collector.rb +45 -0
- data/lib/simplecov/static_coverage_extractor/prism_compat.rb +46 -0
- data/lib/simplecov/static_coverage_extractor/value_position.rb +61 -0
- data/lib/simplecov/static_coverage_extractor/visitor.rb +163 -0
- data/lib/simplecov/static_coverage_extractor.rb +78 -0
- data/lib/simplecov/test_tracker/accessors.rb +34 -0
- data/lib/simplecov/test_tracker/constant_watch.rb +49 -0
- data/lib/simplecov/test_tracker/delta.rb +69 -0
- data/lib/simplecov/test_tracker/framework_hooks.rb +130 -0
- data/lib/simplecov/test_tracker.rb +162 -0
- data/lib/simplecov/unloaded_file_injector.rb +62 -0
- data/lib/simplecov/useless_results_remover.rb +17 -7
- data/lib/simplecov/version.rb +1 -1
- data/lib/simplecov/view_coverage/template_compiler.rb +83 -0
- data/lib/simplecov/view_coverage.rb +55 -0
- data/lib/simplecov-html.rb +5 -0
- data/lib/simplecov.rb +143 -394
- data/lib/simplecov_json_formatter.rb +5 -0
- data/man/simplecov.1 +354 -0
- data/sig/simplecov.rbs +1710 -0
- metadata +183 -61
- data/CHANGELOG.md +0 -191
- data/doc/alternate-formatters.md +0 -71
- data/doc/commercial-services.md +0 -25
- data/doc/editor-integration.md +0 -18
- data/lib/simplecov/combine/files_combiner.rb +0 -24
- data/lib/simplecov/default_formatter.rb +0 -20
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "parallel_result_merger/worker_payload"
|
|
4
|
+
|
|
5
|
+
module SimpleCov
|
|
6
|
+
#
|
|
7
|
+
# `ResultMerger.absorb_results` fanned out across forked workers, driving
|
|
8
|
+
# `SimpleCov.collate(..., processes: N)`. Each worker folds a contiguous
|
|
9
|
+
# slice of the file list and ships the pair back over a pipe.
|
|
10
|
+
#
|
|
11
|
+
# The slices are contiguous and merged back in order, so the fold visits the
|
|
12
|
+
# resultsets in the order the serial fold visits them and the merged result
|
|
13
|
+
# is identical to `SimpleCov.collate`'s, not merely equivalent.
|
|
14
|
+
#
|
|
15
|
+
module ParallelResultMerger
|
|
16
|
+
extend self
|
|
17
|
+
|
|
18
|
+
def merge_and_store(*file_paths, processes:, ignore_timeout: false)
|
|
19
|
+
return ResultMerger.merge_and_store(*file_paths, ignore_timeout: ignore_timeout) if processes < 2
|
|
20
|
+
|
|
21
|
+
result = merge_results(*file_paths, processes: processes, ignore_timeout: ignore_timeout)
|
|
22
|
+
ResultMerger.store_result(result) if result
|
|
23
|
+
result
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def merge_results(*file_paths, processes:, ignore_timeout: false)
|
|
27
|
+
tracked_files = Set.new
|
|
28
|
+
context_maps = ContextMap::Union.new
|
|
29
|
+
pair = absorb_results(file_paths, processes: processes, ignore_timeout: ignore_timeout,
|
|
30
|
+
tracked_files: tracked_files, context_maps: context_maps)
|
|
31
|
+
return ResultMerger.merge_results(*file_paths, ignore_timeout: ignore_timeout) unless pair
|
|
32
|
+
|
|
33
|
+
command_names, coverage = pair
|
|
34
|
+
ResultMerger.create_result(command_names, coverage, tracked_files: tracked_files, contexts: context_maps.map)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Answers nil when the work could not be fanned out and the caller should
|
|
38
|
+
# merge in this process instead. The tracked paths and context-map union a
|
|
39
|
+
# worker's slice carried come back with its payload rather than through a
|
|
40
|
+
# collector block, since that block would be mutating state in the wrong
|
|
41
|
+
# process.
|
|
42
|
+
def absorb_results(file_paths, processes:, ignore_timeout: false, tracked_files: Set.new,
|
|
43
|
+
context_maps: ContextMap::Union.new)
|
|
44
|
+
return nil if processes < 2 || file_paths.size < 2
|
|
45
|
+
# JRuby cannot fork on the JVM and deliberately answers false here, but
|
|
46
|
+
# still defines `Kernel#fork` and raises `NotImplementedError` from it,
|
|
47
|
+
# so probing that instead would send it down the fan-out.
|
|
48
|
+
return nil unless Process.respond_to?(:fork)
|
|
49
|
+
|
|
50
|
+
fan_out(chunk(file_paths, processes), ignore_timeout: ignore_timeout, tracked_files: tracked_files,
|
|
51
|
+
context_maps: context_maps)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def chunk(file_paths, processes)
|
|
55
|
+
groups = [processes, file_paths.size].min
|
|
56
|
+
base, remainder = file_paths.size.divmod(groups)
|
|
57
|
+
remaining = file_paths.dup
|
|
58
|
+
|
|
59
|
+
Array.new(groups) { |index| remaining.shift(base + ((index < remainder) ? 1 : 0)) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# A `fork` that fails here raises, and is left to: the runtimes that never
|
|
63
|
+
# fork are already excluded, so what remains is the OS refusing a process
|
|
64
|
+
# we expected to get, which says something is wrong with the machine rather
|
|
65
|
+
# than with the merge.
|
|
66
|
+
def fan_out(chunks, ignore_timeout:, tracked_files:, context_maps:)
|
|
67
|
+
workers = spawn_workers(chunks, ignore_timeout: ignore_timeout)
|
|
68
|
+
payloads = collect_payloads(workers)
|
|
69
|
+
return nil unless payloads
|
|
70
|
+
|
|
71
|
+
payloads.each { |payload| WorkerPayload.absorb(payload, tracked_files, context_maps) }
|
|
72
|
+
ResultMerger.merge_coverage(*payloads.map { |payload| WorkerPayload.pair(payload) })
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def spawn_workers(chunks, ignore_timeout:)
|
|
76
|
+
workers = [] #: Array[Hash[Symbol, untyped]]
|
|
77
|
+
chunks.each do |chunk|
|
|
78
|
+
workers << spawn_worker(chunk, ignore_timeout: ignore_timeout)
|
|
79
|
+
rescue
|
|
80
|
+
abandon(workers)
|
|
81
|
+
raise
|
|
82
|
+
end
|
|
83
|
+
workers
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def abandon(workers)
|
|
87
|
+
workers.each do |worker|
|
|
88
|
+
worker.fetch(:reader).close
|
|
89
|
+
succeeded?(worker.fetch(:pid))
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The caller cleans up the workers it knows about, but this pipe is ours.
|
|
94
|
+
# The safe navigation keeps the cleanup well-defined when `IO.pipe` itself
|
|
95
|
+
# raised, and Steep cannot type body locals inside an ensure.
|
|
96
|
+
def spawn_worker(chunk, ignore_timeout:)
|
|
97
|
+
reader, writer = IO.pipe
|
|
98
|
+
pid = fork { run_in_child(reader, writer, chunk, ignore_timeout) }
|
|
99
|
+
{pid: pid, reader: reader}
|
|
100
|
+
ensure
|
|
101
|
+
# @type var writer: IO?
|
|
102
|
+
# @type var reader: IO?
|
|
103
|
+
# @type var pid: Integer?
|
|
104
|
+
writer&.close
|
|
105
|
+
reader&.close unless pid
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# `exit!` rather than `exit` because the child must never fall through to
|
|
109
|
+
# the collating process's inherited `at_exit` handlers, SimpleCov's own
|
|
110
|
+
# report generation included.
|
|
111
|
+
def run_in_child(reader, writer, chunk, ignore_timeout)
|
|
112
|
+
reader.close
|
|
113
|
+
exit!(run_worker(chunk, writer, ignore_timeout: ignore_timeout))
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Kept free of the exit itself so it can be exercised in-process.
|
|
117
|
+
def run_worker(chunk, writer, ignore_timeout:)
|
|
118
|
+
Marshal.dump(WorkerPayload.build(chunk, ignore_timeout: ignore_timeout), writer)
|
|
119
|
+
writer.close
|
|
120
|
+
0
|
|
121
|
+
rescue => e
|
|
122
|
+
warn "[SimpleCov]: parallel merge worker failed: #{e.class}: #{e}" if SimpleCov.print_errors
|
|
123
|
+
1
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Answers nil if any worker failed, so the caller redoes the fold serially
|
|
127
|
+
# rather than report a subset of the resultsets as the whole.
|
|
128
|
+
def collect_payloads(workers)
|
|
129
|
+
payloads = drain(workers)
|
|
130
|
+
failed = workers.count { |worker| !succeeded?(worker.fetch(:pid)) }
|
|
131
|
+
return warn_about_failed_workers(failed, workers.size) unless failed.zero? && payloads.all?
|
|
132
|
+
|
|
133
|
+
payloads
|
|
134
|
+
ensure
|
|
135
|
+
workers.each { |worker| worker.fetch(:reader).close }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# A thread per worker, so every pipe is drained while the workers are still
|
|
139
|
+
# writing. A payload larger than the pipe buffer would otherwise block its
|
|
140
|
+
# worker mid-write, and the parent would block reaping a worker that can
|
|
141
|
+
# never finish.
|
|
142
|
+
def drain(workers)
|
|
143
|
+
workers.map { |worker| Thread.new { read_payload(worker.fetch(:reader)) } }.map(&:value)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def read_payload(reader)
|
|
147
|
+
# The writer is a fork of this very process and the pipe never leaves it,
|
|
148
|
+
# so this is our own data coming back through our own kernel buffer, not
|
|
149
|
+
# input.
|
|
150
|
+
Marshal.load(reader) # rubocop:disable Security/MarshalLoad
|
|
151
|
+
rescue
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def succeeded?(pid)
|
|
156
|
+
_pid, status = Process.wait2(pid)
|
|
157
|
+
status.success?
|
|
158
|
+
rescue SystemCallError
|
|
159
|
+
# Errno::ECHILD: nothing left to reap, so there is no status to judge
|
|
160
|
+
# this worker's slice by and we have to assume it did not finish.
|
|
161
|
+
false
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def warn_about_failed_workers(failed, total)
|
|
165
|
+
return unless SimpleCov.print_errors
|
|
166
|
+
|
|
167
|
+
warn "[SimpleCov]: parallel merge did not complete (#{failed} of #{total} workers failed); " \
|
|
168
|
+
"merging the resultsets in this process instead."
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
data/lib/simplecov/process.rb
CHANGED
|
@@ -1,19 +1,41 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
module
|
|
3
|
+
module SimpleCov
|
|
4
4
|
class << self
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
5
|
+
# Forked children inherit at_exit state that is wrong for them:
|
|
6
|
+
# @at_exit_hook_installed may describe a hook the parent already consumed
|
|
7
|
+
# before forking (Minitest autorun runs the suite inside the parent's
|
|
8
|
+
# at_exit), and external_at_exit may point at a Minitest.after_run deferral
|
|
9
|
+
# that is pid-pinned to the parent. Resetting both is what lets the at_fork
|
|
10
|
+
# proc's `SimpleCov.start` install a hook that actually fires (#1227).
|
|
11
|
+
def reset_inherited_at_exit_state!
|
|
12
|
+
@at_exit_hook_installed = false
|
|
13
|
+
self.external_at_exit = false
|
|
14
14
|
end
|
|
15
|
+
end
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
# Prepended onto Process's singleton class so every fork re-runs SimpleCov's
|
|
18
|
+
# at_fork callback in the child. `Process._fork` is the official extension
|
|
19
|
+
# point: `Kernel#fork`, `Process.fork`, and `IO.popen("-")` all funnel
|
|
20
|
+
# through it, and prepending composes with other libraries doing the same.
|
|
21
|
+
module ProcessForkHook
|
|
22
|
+
# The next serial is assigned in the parent, before the fork, so the child
|
|
23
|
+
# inherits its own stable ordinal via copy-on-write, and the child is marked
|
|
24
|
+
# here independent of whatever custom at_fork block the user installed so
|
|
25
|
+
# `final_result_process?` can keep forked workers from each producing the
|
|
26
|
+
# final report (#1171, #1227).
|
|
27
|
+
def _fork
|
|
28
|
+
active = defined?(SimpleCov) && Coverage.running?
|
|
29
|
+
SimpleCov.next_subprocess_serial! if active
|
|
30
|
+
pid = super
|
|
31
|
+
if pid.zero? && active
|
|
32
|
+
SimpleCov.mark_forked_subprocess!
|
|
33
|
+
SimpleCov.reset_inherited_at_exit_state!
|
|
34
|
+
SimpleCov.at_fork.call(::Process.pid)
|
|
35
|
+
end
|
|
36
|
+
pid
|
|
37
|
+
end
|
|
18
38
|
end
|
|
19
39
|
end
|
|
40
|
+
|
|
41
|
+
Process.singleton_class.prepend(SimpleCov::ProcessForkHook)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleCov
|
|
4
|
+
module Production
|
|
5
|
+
# Configuration and store-format failures. Sink errors raised from `store` are
|
|
6
|
+
# not wrapped in this: the runtime rescues them, warns, and retries on the
|
|
7
|
+
# next interval.
|
|
8
|
+
class Error < StandardError
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
require_relative "error"
|
|
7
|
+
|
|
8
|
+
module SimpleCov
|
|
9
|
+
module Production
|
|
10
|
+
# The bundled sink: a single JSON file, union-merged under an exclusive
|
|
11
|
+
# lock so any number of processes on the same host can share it. This is
|
|
12
|
+
# the reference for the sink contract:
|
|
13
|
+
#
|
|
14
|
+
# store(coverage) # {"lib/foo.rb" => [1, 3, 12], ...} of
|
|
15
|
+
# # root-relative paths to sorted line numbers
|
|
16
|
+
#
|
|
17
|
+
# A sink must merge (never replace: many processes each hold only a
|
|
18
|
+
# slice), must be idempotent (the same lines may arrive twice), and
|
|
19
|
+
# signals failure by raising, which makes the runtime keep the delta and
|
|
20
|
+
# retry next interval.
|
|
21
|
+
#
|
|
22
|
+
# `last_seen` stamps each file with the last store that carried it.
|
|
23
|
+
# Oneshot clears on drain, so still-running code re-reports every interval
|
|
24
|
+
# and the stamp tracks real recency, not first sighting. The field is
|
|
25
|
+
# optional on read: a v1 store written before it existed simply has no
|
|
26
|
+
# recency evidence to offer.
|
|
27
|
+
class FileSink
|
|
28
|
+
# The envelope key, which is also how `read` tells a production coverage
|
|
29
|
+
# file from an arbitrary JSON document it must not clobber or misread.
|
|
30
|
+
ENVELOPE = "simplecov_production"
|
|
31
|
+
FORMAT_VERSION = 1
|
|
32
|
+
|
|
33
|
+
attr_reader :path
|
|
34
|
+
|
|
35
|
+
def initialize(path:)
|
|
36
|
+
@path = File.expand_path(path)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def store(coverage)
|
|
40
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
41
|
+
with_exclusive_lock do |file|
|
|
42
|
+
existing = self.class.parse(file.read, path)
|
|
43
|
+
rewrite(file, envelope(existing, coverage))
|
|
44
|
+
end
|
|
45
|
+
true
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Truncates any leftover tail from a previously larger document.
|
|
49
|
+
def rewrite(file, payload)
|
|
50
|
+
file.rewind
|
|
51
|
+
file.write(JSON.generate(payload))
|
|
52
|
+
file.truncate(file.pos)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Raises `Error` for anything that is not a production coverage file,
|
|
56
|
+
# naming the path: misreading an arbitrary JSON file as empty coverage
|
|
57
|
+
# would quietly report every line dead.
|
|
58
|
+
def self.read(path)
|
|
59
|
+
parse(File.read(path), path)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @api private -- shared by `read` and `store`'s read-modify-write. An
|
|
63
|
+
# empty or missing file is a fresh store. JSON answers plain hashes,
|
|
64
|
+
# arrays, and scalars, never a subclass of one, so the shape checks ask
|
|
65
|
+
# about the class itself.
|
|
66
|
+
def self.parse(content, path)
|
|
67
|
+
return {"coverage" => {}, "last_seen" => {}, "started_at" => nil} if content.match?(/\A\s*\z/)
|
|
68
|
+
|
|
69
|
+
inner = envelope_of(JSON.parse(content), path)
|
|
70
|
+
inner["coverage"] = {} unless inner["coverage"].instance_of?(Hash)
|
|
71
|
+
inner["last_seen"] = {} unless inner["last_seen"].instance_of?(Hash)
|
|
72
|
+
inner
|
|
73
|
+
rescue JSON::ParserError => e
|
|
74
|
+
# One line of the parser's complaint, which for some inputs quotes the
|
|
75
|
+
# document back and runs long.
|
|
76
|
+
raise Error, "#{path} is not valid JSON (#{e.message.lines.first.to_s.rstrip})"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The refusal is what keeps an arbitrary JSON file from being misread as
|
|
80
|
+
# empty coverage.
|
|
81
|
+
def self.envelope_of(document, path)
|
|
82
|
+
inner = document[ENVELOPE] if document.instance_of?(Hash)
|
|
83
|
+
raise Error, "#{path} is not a SimpleCov production coverage file" unless inner.instance_of?(Hash)
|
|
84
|
+
|
|
85
|
+
inner
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
# Both halves of the read-modify-write happen through the one handle, so
|
|
91
|
+
# processes sharing the file take turns rather than overwriting each
|
|
92
|
+
# other. The open flags are summed, which for disjoint bits is the same
|
|
93
|
+
# number OR would build and leaves no spelling of the combination
|
|
94
|
+
# without a witness.
|
|
95
|
+
def with_exclusive_lock
|
|
96
|
+
open_file(path, File::RDWR + File::CREAT, 0o644) do |file|
|
|
97
|
+
file.flock(File::LOCK_EX)
|
|
98
|
+
yield file
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# mutant:disable — Ruby 4.0 removed Kernel#open's leading-pipe
|
|
103
|
+
# command mode, so no test can tell `File.open` from `open` here
|
|
104
|
+
# any more. The explicit receiver is kept: on older rubies it is
|
|
105
|
+
# what refuses to run a store path as a command.
|
|
106
|
+
def open_file(name, mode, perm, &)
|
|
107
|
+
File.open(name, mode, perm, &)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def merge(existing, incoming)
|
|
111
|
+
incoming.each_with_object(existing) do |(file, lines), merged|
|
|
112
|
+
merged[file] = ((merged[file] || []) | lines).sort
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def envelope(existing, incoming)
|
|
117
|
+
now = Time.now.utc.iso8601
|
|
118
|
+
{
|
|
119
|
+
ENVELOPE => {
|
|
120
|
+
"format_version" => FORMAT_VERSION,
|
|
121
|
+
"started_at" => existing["started_at"] || now,
|
|
122
|
+
"updated_at" => now,
|
|
123
|
+
"coverage" => merge(existing.fetch("coverage"), incoming).sort.to_h,
|
|
124
|
+
"last_seen" => existing.fetch("last_seen").merge(incoming.transform_values { now }).sort.to_h
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "coverage"
|
|
4
|
+
require_relative "production/error"
|
|
5
|
+
|
|
6
|
+
#
|
|
7
|
+
# Production coverage mode (#1271). Deliberately loadable on its own:
|
|
8
|
+
# `require "simplecov/production"` pulls in none of the reporting machinery,
|
|
9
|
+
# because a production process wants a measurement tap and nothing else.
|
|
10
|
+
# Nothing here runs unless `SimpleCov::Production.start` is called explicitly.
|
|
11
|
+
#
|
|
12
|
+
module SimpleCov
|
|
13
|
+
module Production
|
|
14
|
+
class << self
|
|
15
|
+
# Answers false, with a warning naming why, when it safely declines
|
|
16
|
+
# (already running, another Coverage owner such as a test suite, or a
|
|
17
|
+
# runtime without oneshot lines). Configuration mistakes raise `Error`
|
|
18
|
+
# instead: a typo'd interval should fail deployment, not quietly measure
|
|
19
|
+
# wrong.
|
|
20
|
+
#
|
|
21
|
+
# In a forking server, call this from the worker-boot hook (Puma's
|
|
22
|
+
# `on_worker_boot`, Unicorn's `after_fork`): the flush thread does not
|
|
23
|
+
# survive a fork, and `start` in the child picks the inherited
|
|
24
|
+
# measurement back up.
|
|
25
|
+
def start(root: Dir.pwd, sink: nil, flush_interval: 60, flush_jitter: nil, sample_rate: 1.0,
|
|
26
|
+
max_buffered_lines: 1_000_000)
|
|
27
|
+
validate!(flush_interval, flush_jitter, sample_rate, max_buffered_lines)
|
|
28
|
+
return warn_decline("SimpleCov::Production is already running") if running?
|
|
29
|
+
return false unless claim_coverage
|
|
30
|
+
|
|
31
|
+
configure(root, sink, flush_interval: flush_interval, flush_jitter: flush_jitter,
|
|
32
|
+
sample_rate: sample_rate, max_buffered_lines: max_buffered_lines)
|
|
33
|
+
@running = true
|
|
34
|
+
@pid = Process.pid
|
|
35
|
+
install_at_exit
|
|
36
|
+
spawn_flush_thread
|
|
37
|
+
true
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Both are small Integers, so identity settles it: a measurement belongs to
|
|
41
|
+
# the process that started it and to no other.
|
|
42
|
+
def running?
|
|
43
|
+
!!@running && @pid.equal?(Process.pid)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Called by the flush thread every interval; public so a deploy hook (or a
|
|
47
|
+
# signal handler) can force one. An empty delta counts as accepted.
|
|
48
|
+
def flush
|
|
49
|
+
return false unless running?
|
|
50
|
+
|
|
51
|
+
@mutex.synchronize do
|
|
52
|
+
pull_pending
|
|
53
|
+
push_pending
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def stop
|
|
58
|
+
return false unless running?
|
|
59
|
+
|
|
60
|
+
@running = false
|
|
61
|
+
wake_flush_thread
|
|
62
|
+
@thread&.join
|
|
63
|
+
@mutex.synchronize do
|
|
64
|
+
pull_pending
|
|
65
|
+
push_pending
|
|
66
|
+
Coverage.result(stop: true, clear: true)
|
|
67
|
+
end
|
|
68
|
+
true
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @api private -- clears module state between tests. The at_exit
|
|
72
|
+
# registration flag survives on purpose: one hook per process.
|
|
73
|
+
def reset!
|
|
74
|
+
@running = false
|
|
75
|
+
wake_flush_thread if @thread
|
|
76
|
+
@thread&.join
|
|
77
|
+
@thread = nil
|
|
78
|
+
@pending = {}
|
|
79
|
+
@pid = nil
|
|
80
|
+
@started_coverage = false
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def validate!(flush_interval, flush_jitter, sample_rate, max_buffered_lines)
|
|
86
|
+
raise Error, "flush_interval must be a positive number of seconds" unless flush_interval.positive?
|
|
87
|
+
|
|
88
|
+
validate_jitter!(flush_jitter)
|
|
89
|
+
raise Error, "sample_rate must be within (0, 1]" unless sample_rate.positive? && sample_rate <= 1
|
|
90
|
+
raise Error, "max_buffered_lines must be positive" unless max_buffered_lines.positive?
|
|
91
|
+
return unless sample_rate < 1 && !Coverage.respond_to?(:suspend)
|
|
92
|
+
|
|
93
|
+
raise Error, "sample_rate below 1.0 needs Coverage.suspend, which this Ruby does not provide"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def validate_jitter!(flush_jitter)
|
|
97
|
+
return if flush_jitter.nil? || (flush_jitter.is_a?(Numeric) && flush_jitter >= 0)
|
|
98
|
+
|
|
99
|
+
raise Error, "flush_jitter must be a non-negative number of seconds"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# A running Coverage belongs to someone else, unless this very module
|
|
103
|
+
# started it before a fork, in which case the child inherits the
|
|
104
|
+
# measurement and only needs its own flush thread.
|
|
105
|
+
def claim_coverage
|
|
106
|
+
if Coverage.running?
|
|
107
|
+
return true if @started_coverage && @pid && !@pid.equal?(Process.pid)
|
|
108
|
+
|
|
109
|
+
return warn_decline("Coverage is already running (a test suite or another tool owns it)")
|
|
110
|
+
end
|
|
111
|
+
return warn_decline("this runtime has no oneshot lines support") unless oneshot_supported?
|
|
112
|
+
|
|
113
|
+
Coverage.start(oneshot_lines: true)
|
|
114
|
+
@started_coverage = true
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def oneshot_supported?
|
|
118
|
+
!Coverage.respond_to?(:supported?) || Coverage.supported?(:oneshot_lines)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def configure(root, sink, flush_interval:, flush_jitter:, sample_rate:, max_buffered_lines:)
|
|
122
|
+
@root_prefix = File.expand_path(root) + File::SEPARATOR
|
|
123
|
+
@sink = sink || default_sink(root)
|
|
124
|
+
@flush_interval = flush_interval
|
|
125
|
+
@flush_jitter = flush_jitter || (flush_interval / 10.0)
|
|
126
|
+
@sample_rate = sample_rate
|
|
127
|
+
@max_buffered_lines = max_buffered_lines
|
|
128
|
+
@pending = {} #: Hash[String, Set[Integer]]
|
|
129
|
+
@measuring = true
|
|
130
|
+
@mutex = Mutex.new
|
|
131
|
+
@waiter = ConditionVariable.new
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def default_sink(root)
|
|
135
|
+
FileSink.new(path: File.join(root, "tmp", "simplecov", "production.json"))
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def spawn_flush_thread
|
|
139
|
+
thread = Thread.new { flush_loop }
|
|
140
|
+
thread.name = "SimpleCov::Production flusher"
|
|
141
|
+
@thread = thread
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# The condition variable rather than a bare sleep is what lets `stop`
|
|
145
|
+
# return promptly instead of waiting out a long interval.
|
|
146
|
+
def flush_loop
|
|
147
|
+
@mutex.synchronize do
|
|
148
|
+
while @running
|
|
149
|
+
@waiter.wait(@mutex, interval_with_jitter)
|
|
150
|
+
cycle if @running
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# A fresh share is drawn per wait: a fleet of workers booted together
|
|
156
|
+
# would otherwise contend on the shared sink at the same instant every
|
|
157
|
+
# interval, forever.
|
|
158
|
+
def interval_with_jitter
|
|
159
|
+
@flush_interval + (rand * @flush_jitter)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def wake_flush_thread
|
|
163
|
+
@mutex.synchronize { @waiter.signal }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def cycle
|
|
167
|
+
resample
|
|
168
|
+
pull_pending
|
|
169
|
+
push_pending
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Duty-cycle sampling: with `sample_rate` 0.25, roughly one interval in
|
|
173
|
+
# four measures and the rest are suspended, capping steady-state overhead
|
|
174
|
+
# while every line still gets its chance over time.
|
|
175
|
+
def resample
|
|
176
|
+
return unless @sample_rate < 1
|
|
177
|
+
|
|
178
|
+
wanted = rand < @sample_rate
|
|
179
|
+
if wanted && !@measuring
|
|
180
|
+
Coverage.resume
|
|
181
|
+
elsif !wanted && @measuring
|
|
182
|
+
Coverage.suspend
|
|
183
|
+
end
|
|
184
|
+
@measuring = wanted
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Clearing on read means a line executed again later re-reports, which is
|
|
188
|
+
# what lets a dropped buffer heal for still-hot code.
|
|
189
|
+
def pull_pending
|
|
190
|
+
Coverage.result(stop: false, clear: true).each do |path, data|
|
|
191
|
+
lines = data[:oneshot_lines]
|
|
192
|
+
next if lines.nil? || lines.empty?
|
|
193
|
+
|
|
194
|
+
relative = relativize(path)
|
|
195
|
+
next unless relative
|
|
196
|
+
|
|
197
|
+
(@pending[relative] ||= Set.new).merge(lines)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# On failure the buffer is kept for the next interval, bounded by the
|
|
202
|
+
# ceiling so a long outage cannot grow memory without limit.
|
|
203
|
+
def push_pending
|
|
204
|
+
return true if @pending.empty?
|
|
205
|
+
|
|
206
|
+
@sink.store(@pending.transform_values(&:sort))
|
|
207
|
+
@pending.clear
|
|
208
|
+
true
|
|
209
|
+
rescue => e
|
|
210
|
+
warn "[SimpleCov::Production] flush failed (#{e.class}: #{e}); retrying next interval"
|
|
211
|
+
enforce_ceiling
|
|
212
|
+
false
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Oneshot's re-reporting is what makes dropping safe-ish: lines still being
|
|
216
|
+
# executed report again after the drop, and only code that ran during the
|
|
217
|
+
# outage and never again is lost.
|
|
218
|
+
def enforce_ceiling
|
|
219
|
+
return if @pending.sum { |_path, lines| lines.size } <= @max_buffered_lines
|
|
220
|
+
|
|
221
|
+
@pending.clear
|
|
222
|
+
warn "[SimpleCov::Production] buffer ceiling (#{@max_buffered_lines} lines) exceeded " \
|
|
223
|
+
"while the sink was unreachable; dropping buffered coverage"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def relativize(path)
|
|
227
|
+
return nil unless path.start_with?(@root_prefix)
|
|
228
|
+
|
|
229
|
+
path[@root_prefix.length..]
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def warn_decline(reason)
|
|
233
|
+
warn "[SimpleCov::Production] not starting: #{reason}"
|
|
234
|
+
false
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def install_at_exit
|
|
238
|
+
return if @at_exit_installed
|
|
239
|
+
|
|
240
|
+
@at_exit_installed = true
|
|
241
|
+
at_exit do
|
|
242
|
+
stop
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
require_relative "production/file_sink"
|
|
@@ -3,16 +3,19 @@
|
|
|
3
3
|
SimpleCov.profiles.define "rails" do
|
|
4
4
|
load_profile "test_frameworks"
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
skip %r{\Aconfig/}
|
|
7
|
+
skip %r{\Adb/}
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
9
|
+
group "Channels", "app/channels"
|
|
10
|
+
group "Controllers", "app/controllers"
|
|
11
|
+
group "Helpers", "app/helpers"
|
|
12
|
+
group "Jobs", %w[app/jobs app/workers]
|
|
13
|
+
group "Libraries", "lib/"
|
|
14
|
+
group "Mailers", "app/mailers"
|
|
15
|
+
group "Models", "app/models"
|
|
16
|
+
group "Views", "app/views"
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
@tracked_files = "{app,lib}/**/*.rb"
|
|
19
|
+
|
|
20
|
+
merge_subprocesses true
|
|
18
21
|
end
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
SimpleCov.profiles.define "root_filter" do
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
add_filter do |src|
|
|
7
|
-
root_filter ||= /\A#{Regexp.escape(SimpleCov.root + File::SEPARATOR)}/io
|
|
8
|
-
src.filename !~ root_filter
|
|
4
|
+
skip do |src|
|
|
5
|
+
src.filename !~ SimpleCov::UselessResultsRemover.root_regex
|
|
9
6
|
end
|
|
10
7
|
end
|