fastlane-plugin-stream_actions 0.4.10 → 0.4.12
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/lib/fastlane/plugin/stream_actions/actions/build_test_coverage_map.rb +243 -0
- data/lib/fastlane/plugin/stream_actions/actions/select_impacted_tests.rb +369 -0
- data/lib/fastlane/plugin/stream_actions/helper/test_coverage_map_helper.rb +57 -0
- data/lib/fastlane/plugin/stream_actions/version.rb +1 -1
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7e20315e989110210371734616854eb1cf055d2318fe8c1c7031ee5642eb95cf
|
|
4
|
+
data.tar.gz: 87fab4f22f00b8771fdede017075a4df012942c24baae580613313e781e55745
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cf84b704871864755de8317c4e0e9aef2ffd38275f6a8ab8903e3e1f7289c7fdf7563e77aed2aa27f2a61e26621f84c9a9587d659683ae86b1b8550134ec9288
|
|
7
|
+
data.tar.gz: e784f17eb5d31bb15d53209d69e58135a07f7d87d3a6ea03461ca8b627e63da6cda99b80655a97f62bba9417e8d7c49496edc8c61314ada0eeca797808ed4fa5
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'open3'
|
|
3
|
+
require 'set'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
6
|
+
module Fastlane
|
|
7
|
+
module Actions
|
|
8
|
+
class BuildTestCoverageMapAction < Action
|
|
9
|
+
def self.run(params)
|
|
10
|
+
source_root = File.expand_path(params[:source_root] || Dir.pwd)
|
|
11
|
+
test_dirs = Dir.glob(File.join(params[:coverage_dir], '*', '*')).select { |dir| File.directory?(dir) }
|
|
12
|
+
UI.user_error!("No per-test coverage found in '#{params[:coverage_dir]}'") if test_dirs.empty?
|
|
13
|
+
|
|
14
|
+
binaries = params[:binaries].select { |binary| File.exist?(binary) }
|
|
15
|
+
UI.user_error!('None of the instrumented binaries exist') if binaries.empty?
|
|
16
|
+
|
|
17
|
+
UI.message("Building the coverage map of #{test_dirs.size} tests ⌛️")
|
|
18
|
+
sources = []
|
|
19
|
+
tests = {}
|
|
20
|
+
lock = Mutex.new
|
|
21
|
+
queue = Queue.new
|
|
22
|
+
test_dirs.each { |dir| queue << dir }
|
|
23
|
+
|
|
24
|
+
workers = Array.new([params[:jobs], test_dirs.size].min) do
|
|
25
|
+
Thread.new do
|
|
26
|
+
until queue.empty?
|
|
27
|
+
dir = begin
|
|
28
|
+
queue.pop(true)
|
|
29
|
+
rescue ThreadError
|
|
30
|
+
break
|
|
31
|
+
end
|
|
32
|
+
test = dir.split('/').last(2).join('/')
|
|
33
|
+
report = coverage_report(dir, binaries)
|
|
34
|
+
next if report.nil?
|
|
35
|
+
|
|
36
|
+
lock.synchronize do
|
|
37
|
+
sources |= report[:all].map { |file| relative_path(file, source_root) }.compact
|
|
38
|
+
tests[test] = report[:covered].map { |file| relative_path(file, source_root) }.compact
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
workers.each(&:join)
|
|
44
|
+
|
|
45
|
+
carry_over_failed_tests(tests, sources, params) unless params[:failed_tests].to_a.empty?
|
|
46
|
+
drop_untracked(tests, sources, source_root)
|
|
47
|
+
|
|
48
|
+
map = {
|
|
49
|
+
'version' => Helper::TestCoverageMapHelper::MAP_VERSION,
|
|
50
|
+
'generated_at' => Time.now.utc.iso8601,
|
|
51
|
+
'commit' => params[:commit],
|
|
52
|
+
'sources' => sources.sort,
|
|
53
|
+
'tests' => tests.sort.to_h
|
|
54
|
+
}
|
|
55
|
+
write(map, params[:output_path])
|
|
56
|
+
UI.success("Coverage map of #{tests.size} tests and #{map['sources'].size} sources saved to #{params[:output_path]}")
|
|
57
|
+
map
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Returns the instrumented files (`all`) and the files with at least one executed line (`covered`).
|
|
61
|
+
def self.coverage_report(dir, binaries)
|
|
62
|
+
profiles = Dir.glob(File.join(dir, '**', '*.profraw'))
|
|
63
|
+
if profiles.empty?
|
|
64
|
+
UI.important("No profiles in #{dir}, skipping")
|
|
65
|
+
return nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
profdata = File.join(dir, 'merged.profdata')
|
|
69
|
+
_, err, status = Open3.capture3('xcrun', 'llvm-profdata', 'merge', '-sparse', *profiles, '-o', profdata)
|
|
70
|
+
unless status.success?
|
|
71
|
+
UI.important("Could not merge the profiles in #{dir}: #{err}")
|
|
72
|
+
return nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
objects = binaries.drop(1).flat_map { |binary| ['-object', binary] }
|
|
76
|
+
out, err, status = Open3.capture3(
|
|
77
|
+
'xcrun', 'llvm-cov', 'export', '-format=text', '-summary-only',
|
|
78
|
+
"-instr-profile=#{profdata}", binaries.first, *objects
|
|
79
|
+
)
|
|
80
|
+
unless status.success?
|
|
81
|
+
UI.important("Could not export the coverage of #{dir}: #{err}")
|
|
82
|
+
return nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
files = JSON.parse(out).fetch('data').flat_map { |data| data.fetch('files') }
|
|
86
|
+
{
|
|
87
|
+
all: files.map { |file| file['filename'] },
|
|
88
|
+
covered: files.select { |file| file.dig('summary', 'lines', 'covered').to_i.positive? }.map { |file| file['filename'] }
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# A failed test stops early, so its fresh coverage can miss code it normally runs.
|
|
93
|
+
def self.carry_over_failed_tests(tests, sources, params)
|
|
94
|
+
path = params[:previous_map]
|
|
95
|
+
if path.nil? && params[:previous_map_artifact]
|
|
96
|
+
path = Helper::TestCoverageMapHelper.download_map(
|
|
97
|
+
coverage_map_artifact: params[:previous_map_artifact],
|
|
98
|
+
coverage_map_branch: params[:previous_map_branch],
|
|
99
|
+
github_repository: params[:github_repository]
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
return if path.nil? || !File.exist?(path)
|
|
103
|
+
|
|
104
|
+
previous = Helper::TestCoverageMapHelper.read_map(path)
|
|
105
|
+
params[:failed_tests].to_a.each do |failed_test|
|
|
106
|
+
test = failed_test.split('/').last(2).join('/')
|
|
107
|
+
previous_files = previous['tests'][test]
|
|
108
|
+
next if previous_files.nil?
|
|
109
|
+
|
|
110
|
+
tests[test] = (tests[test].to_a | previous_files)
|
|
111
|
+
sources.concat(previous_files - sources)
|
|
112
|
+
UI.message("Kept the previous coverage of the failed #{test}")
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Keeps sources the repository tracks, dropping e.g. package checkouts cloned inside it.
|
|
117
|
+
def self.drop_untracked(tests, sources, source_root)
|
|
118
|
+
tracked = Helper::TestCoverageMapHelper.command('git', '-C', source_root, 'ls-files').split("\n").to_set
|
|
119
|
+
sources.select! { |file| tracked.include?(file) }
|
|
120
|
+
tests.each_value { |files| files.select! { |file| tracked.include?(file) } }
|
|
121
|
+
rescue StandardError => e
|
|
122
|
+
UI.important("Could not list the tracked files, keeping every source: #{e.message}")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def self.relative_path(file, source_root)
|
|
126
|
+
return nil unless file.start_with?("#{source_root}/")
|
|
127
|
+
|
|
128
|
+
file.delete_prefix("#{source_root}/")
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Stores file indices instead of paths, which keeps the map an order of magnitude smaller.
|
|
132
|
+
def self.write(map, path)
|
|
133
|
+
index = map['sources'].each_with_index.to_h
|
|
134
|
+
compact = map.merge('tests' => map['tests'].transform_values { |files| files.map { |file| index.fetch(file) }.sort })
|
|
135
|
+
File.write(path, JSON.generate(compact))
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
#####################################################
|
|
139
|
+
# @!group Documentation
|
|
140
|
+
#####################################################
|
|
141
|
+
|
|
142
|
+
def self.description
|
|
143
|
+
'Builds a map of which source files every test executes, from per-test LLVM coverage profiles'
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def self.details
|
|
147
|
+
<<~DETAILS
|
|
148
|
+
Expects one folder per test, `<coverage_dir>/<TestClass>/<testMethod>/`, holding the `.profraw` files
|
|
149
|
+
the app wrote while that test ran (e.g. via `LLVM_PROFILE_FILE=<dir>/<TestClass>/<testMethod>/%m_%p%c.profraw`
|
|
150
|
+
in the app's launch environment). Each folder is merged with `llvm-profdata` and exported with `llvm-cov`
|
|
151
|
+
against the instrumented `binaries`. The resulting JSON map feeds `select_impacted_tests`.
|
|
152
|
+
DETAILS
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def self.return_value
|
|
156
|
+
'Hash with `version`, `generated_at`, `commit`, `sources` (every instrumented file) and `tests` (`Class/test` => covered files).'
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def self.available_options
|
|
160
|
+
[
|
|
161
|
+
FastlaneCore::ConfigItem.new(
|
|
162
|
+
key: :coverage_dir,
|
|
163
|
+
description: 'Folder with per-test profiles, laid out as <TestClass>/<testMethod>/*.profraw',
|
|
164
|
+
verify_block: proc { |path| UI.user_error!("Coverage folder '#{path}' does not exist") unless File.directory?(path) }
|
|
165
|
+
),
|
|
166
|
+
FastlaneCore::ConfigItem.new(
|
|
167
|
+
key: :binaries,
|
|
168
|
+
description: 'Instrumented binaries the profiles belong to, e.g. the app executable (or its .debug.dylib) and every SDK framework',
|
|
169
|
+
type: Array
|
|
170
|
+
),
|
|
171
|
+
FastlaneCore::ConfigItem.new(
|
|
172
|
+
key: :source_root,
|
|
173
|
+
description: 'Repository root, the current directory by default (fastlane runs actions from the repository root). ' \
|
|
174
|
+
'Paths in the map are relative to it, and files outside it (e.g. SPM checkouts) are dropped',
|
|
175
|
+
optional: true
|
|
176
|
+
),
|
|
177
|
+
FastlaneCore::ConfigItem.new(
|
|
178
|
+
key: :output_path,
|
|
179
|
+
description: 'Where to write the JSON map'
|
|
180
|
+
),
|
|
181
|
+
FastlaneCore::ConfigItem.new(
|
|
182
|
+
key: :commit,
|
|
183
|
+
description: 'Commit the map was built from',
|
|
184
|
+
optional: true,
|
|
185
|
+
default_value: ENV['GITHUB_SHA']
|
|
186
|
+
),
|
|
187
|
+
FastlaneCore::ConfigItem.new(
|
|
188
|
+
key: :previous_map,
|
|
189
|
+
description: 'Previous map to take the coverage of failed_tests from, as a failed test stops before running all of its code',
|
|
190
|
+
optional: true
|
|
191
|
+
),
|
|
192
|
+
FastlaneCore::ConfigItem.new(
|
|
193
|
+
key: :previous_map_artifact,
|
|
194
|
+
description: 'GitHub Actions artifact to download the previous map from when previous_map is not set',
|
|
195
|
+
optional: true
|
|
196
|
+
),
|
|
197
|
+
FastlaneCore::ConfigItem.new(
|
|
198
|
+
key: :previous_map_branch,
|
|
199
|
+
description: 'Branch whose artifact to download',
|
|
200
|
+
default_value: 'develop'
|
|
201
|
+
),
|
|
202
|
+
FastlaneCore::ConfigItem.new(
|
|
203
|
+
env_name: 'GITHUB_REPOSITORY',
|
|
204
|
+
key: :github_repository,
|
|
205
|
+
description: 'Repository in "owner/repo" form, for downloading the artifact',
|
|
206
|
+
optional: true
|
|
207
|
+
),
|
|
208
|
+
FastlaneCore::ConfigItem.new(
|
|
209
|
+
key: :failed_tests,
|
|
210
|
+
description: 'Tests that failed in this run, as Class/test or Target/Class/test',
|
|
211
|
+
type: Array,
|
|
212
|
+
optional: true,
|
|
213
|
+
default_value: []
|
|
214
|
+
),
|
|
215
|
+
FastlaneCore::ConfigItem.new(
|
|
216
|
+
key: :jobs,
|
|
217
|
+
description: 'How many tests to process in parallel',
|
|
218
|
+
type: Integer,
|
|
219
|
+
default_value: 8
|
|
220
|
+
)
|
|
221
|
+
]
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def self.example_code
|
|
225
|
+
[
|
|
226
|
+
'build_test_coverage_map(
|
|
227
|
+
coverage_dir: "test_output/coverage",
|
|
228
|
+
binaries: ["App.app/App.debug.dylib", "App.app/Frameworks/SDK.framework/SDK"],
|
|
229
|
+
output_path: "test_output/e2e_coverage_map.json"
|
|
230
|
+
)'
|
|
231
|
+
]
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def self.category
|
|
235
|
+
:testing
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def self.is_supported?(platform)
|
|
239
|
+
[:ios, :mac].include?(platform)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'set'
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
5
|
+
module Fastlane
|
|
6
|
+
module Actions
|
|
7
|
+
class SelectImpactedTestsAction < Action
|
|
8
|
+
CODE_EXTENSIONS = %w[.swift .m .mm .h .kt .java .dart .js .jsx .ts .tsx].freeze
|
|
9
|
+
MODIFIERS = '(?:(?:public|open|internal|fileprivate|private|final|indirect|nonisolated|data|sealed|abstract|export|default)\\s+)*'
|
|
10
|
+
DECLARATION = /^\s*(?:@\w+(?:\([^)\n]*\))?\s+)*#{MODIFIERS}(?:class|struct|enum|protocol|actor|typealias|extension|interface|object)\s+([A-Za-z_]\w*)/.freeze
|
|
11
|
+
|
|
12
|
+
def self.run(params)
|
|
13
|
+
source_root = File.expand_path(params[:source_root] || Dir.pwd)
|
|
14
|
+
all_tests = params[:all_tests]
|
|
15
|
+
selection = Dir.chdir(source_root) { select(params, all_tests, source_root) }
|
|
16
|
+
|
|
17
|
+
if selection['run_all']
|
|
18
|
+
UI.important("Running all #{all_tests.size} tests: #{selection['reasons'].join('; ')}")
|
|
19
|
+
else
|
|
20
|
+
UI.important("Running #{selection['tests'].size} of #{all_tests.size} tests")
|
|
21
|
+
selection['reasons'].each { |reason| UI.message(" #{reason}") }
|
|
22
|
+
end
|
|
23
|
+
File.write(params[:output_path], JSON.pretty_generate(selection)) if params[:output_path]
|
|
24
|
+
selection
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.select(params, all_tests, source_root)
|
|
28
|
+
changes = changed_files(params)
|
|
29
|
+
return run_all(all_tests, 'the changed files are unknown') if changes.nil?
|
|
30
|
+
return selection(all_tests, [], ['no changed files']) if changes.empty?
|
|
31
|
+
|
|
32
|
+
forcing = changes.keys.find { |file| matches_any?(file, params[:run_all_paths]) && !only_ignored_lines?(file, params) }
|
|
33
|
+
return run_all(all_tests, "#{forcing} affects every test") if forcing
|
|
34
|
+
|
|
35
|
+
map = load_map(params)
|
|
36
|
+
return run_all(all_tests, 'no coverage map') if map.nil?
|
|
37
|
+
|
|
38
|
+
age_days = (Time.now.utc - Time.parse(map['generated_at'])) / 86_400
|
|
39
|
+
return run_all(all_tests, "the coverage map is #{age_days.round(1)} days old") if age_days > params[:max_map_age_days]
|
|
40
|
+
|
|
41
|
+
tests_by_file = Hash.new { |hash, key| hash[key] = [] }
|
|
42
|
+
map['tests'].each { |test, files| files.each { |file| tests_by_file[file] << test } }
|
|
43
|
+
known_sources = map['sources'].to_set
|
|
44
|
+
mapped_count = map['tests'].size
|
|
45
|
+
|
|
46
|
+
selected = []
|
|
47
|
+
reasons = []
|
|
48
|
+
changes.each do |file, status|
|
|
49
|
+
if matches_any?(file, params[:ignore_paths])
|
|
50
|
+
next
|
|
51
|
+
elsif matches_any?(file, params[:test_paths])
|
|
52
|
+
classes = test_classes(file, source_root)
|
|
53
|
+
tests = all_tests.select { |id| classes.include?(test_key(id).split('/').first) }
|
|
54
|
+
selected.concat(tests)
|
|
55
|
+
reasons << "#{file}: #{tests.size} tests of #{classes.join(', ')}"
|
|
56
|
+
elsif known_sources.include?(file)
|
|
57
|
+
tests = tests_by_file[file]
|
|
58
|
+
selected.concat(impacted(tests, all_tests, mapped_count, params))
|
|
59
|
+
reasons << "#{file}: #{describe(tests, mapped_count, params)}"
|
|
60
|
+
elsif matches_any?(file, params[:source_paths])
|
|
61
|
+
return run_all(all_tests, "#{file} is not in the coverage map") unless CODE_EXTENSIONS.include?(File.extname(file))
|
|
62
|
+
|
|
63
|
+
if status == 'A' || status == 'D'
|
|
64
|
+
reasons << "#{file}: #{status == 'A' ? 'added' : 'deleted'}, so the files using it changed too"
|
|
65
|
+
next
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Declaration-only files (models, protocols, constants) have no executable regions, so coverage
|
|
69
|
+
# never lists them. Their declarations still matter, e.g. a Codable key, so run the tests of the
|
|
70
|
+
# mapped files that reference the declared types.
|
|
71
|
+
names = declared_names(file, source_root, params)
|
|
72
|
+
return run_all(all_tests, "#{file} is not in the coverage map and declares no types") if names.empty?
|
|
73
|
+
|
|
74
|
+
users = referencing_files(names, known_sources - [file], source_root)
|
|
75
|
+
tests = users.flat_map { |user| tests_by_file[user] }.uniq
|
|
76
|
+
selected.concat(impacted(tests, all_tests, mapped_count, params))
|
|
77
|
+
reasons << "#{file}: #{describe(tests, mapped_count, params)} of the #{users.size} mapped files using #{names.join(', ')}"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
mapped = map['tests'].keys.to_set
|
|
82
|
+
unmapped = all_tests.reject { |id| mapped.include?(test_key(id)) }
|
|
83
|
+
unless unmapped.empty?
|
|
84
|
+
selected.concat(unmapped)
|
|
85
|
+
reasons << "#{unmapped.size} tests that are not in the coverage map yet"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
selection(all_tests, selected.uniq, reasons)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Returns { path => git status letter }, or nil when the diff cannot be computed.
|
|
92
|
+
def self.changed_files(params)
|
|
93
|
+
return params[:changed_files].map { |file| [file, 'M'] }.to_h if params[:changed_files]
|
|
94
|
+
|
|
95
|
+
range = diff_range(params)
|
|
96
|
+
if range.nil?
|
|
97
|
+
UI.important('Pass changed_files or diff_base, or run on a pull request merge commit')
|
|
98
|
+
return nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
output = Helper::TestCoverageMapHelper.command('git', 'diff', '--name-status', '--no-renames', range)
|
|
102
|
+
output.split("\n").map do |line|
|
|
103
|
+
status, path = line.split("\t", 2)
|
|
104
|
+
[path, status[0]]
|
|
105
|
+
end.to_h
|
|
106
|
+
rescue StandardError => e
|
|
107
|
+
UI.important("Could not list the changed files: #{e.message}")
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def self.diff_range(params)
|
|
112
|
+
base = params[:diff_base]
|
|
113
|
+
if base.nil?
|
|
114
|
+
parents = Helper::TestCoverageMapHelper.command('git', 'rev-list', '--parents', '-n', '1', 'HEAD').split
|
|
115
|
+
# GitHub checks out pull requests as a merge commit whose first parent is the base branch tip.
|
|
116
|
+
base = 'HEAD^1' if parents.size == 3
|
|
117
|
+
end
|
|
118
|
+
base.nil? ? nil : "#{base}...#{params[:diff_head]}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# True when every added or removed line of the file matches one of its ignored_diff_lines patterns.
|
|
122
|
+
def self.only_ignored_lines?(file, params)
|
|
123
|
+
patterns = params[:ignored_diff_lines].to_h.find { |path, _| matches_any?(file, [path]) }&.last
|
|
124
|
+
return false if patterns.nil? || params[:changed_files]
|
|
125
|
+
|
|
126
|
+
regexes = patterns.map { |pattern| Regexp.new(pattern) }
|
|
127
|
+
lines = Helper::TestCoverageMapHelper.command('git', 'diff', '-U0', diff_range(params), '--', file).lines.map(&:chomp)
|
|
128
|
+
changed = lines.select { |line| line.match?(/^[+-]/) && !line.match?(/^(\+\+\+|---) /) }.map { |line| line[1..-1] }
|
|
129
|
+
changed.all? { |line| regexes.any? { |regex| line.match?(regex) } }
|
|
130
|
+
rescue StandardError => e
|
|
131
|
+
UI.important("Could not read the diff of #{file}: #{e.message}")
|
|
132
|
+
false
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def self.load_map(params)
|
|
136
|
+
path = params[:coverage_map]
|
|
137
|
+
path = Helper::TestCoverageMapHelper.download_map(params) if (path.nil? || !File.exist?(path)) && params[:coverage_map_artifact]
|
|
138
|
+
return nil if path.nil? || !File.exist?(path)
|
|
139
|
+
|
|
140
|
+
Helper::TestCoverageMapHelper.read_map(path)
|
|
141
|
+
rescue StandardError => e
|
|
142
|
+
UI.important("Could not read the coverage map: #{e.message}")
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def self.test_classes(file, source_root)
|
|
147
|
+
path = File.join(source_root, file)
|
|
148
|
+
classes = File.exist?(path) ? File.read(path).scan(/^\s*(?:(?:public|open|final|internal)\s+)*class\s+(\w+)/).flatten : []
|
|
149
|
+
classes.empty? ? [File.basename(file, '.*')] : classes
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Code nearly every test runs (app start, login, the main screen) would select everything. With
|
|
153
|
+
# hot_test_ratio such a change runs one test per class instead, leaving the rest to the full nightly run.
|
|
154
|
+
def self.impacted(tests, all_tests, mapped_count, params)
|
|
155
|
+
return smoke_tests(all_tests) if hot?(tests, mapped_count, params)
|
|
156
|
+
|
|
157
|
+
all_tests.select { |id| tests.include?(test_key(id)) }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def self.hot?(tests, mapped_count, params)
|
|
161
|
+
params[:hot_test_ratio] && mapped_count.positive? && tests.size >= params[:hot_test_ratio] * mapped_count
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def self.smoke_tests(all_tests)
|
|
165
|
+
all_tests.group_by { |id| test_key(id).split('/').first }.values.map(&:first)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def self.describe(tests, mapped_count, params)
|
|
169
|
+
hot?(tests, mapped_count, params) ? "run by #{tests.size} tests, so one test per class" : "#{tests.size} tests"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def self.declared_names(file, source_root, params)
|
|
173
|
+
content = begin
|
|
174
|
+
params[:changed_files] ? File.read(File.join(source_root, file)) : Helper::TestCoverageMapHelper.command('git', 'show', "#{params[:diff_head]}:#{file}")
|
|
175
|
+
rescue StandardError
|
|
176
|
+
''
|
|
177
|
+
end
|
|
178
|
+
content.scan(DECLARATION).flatten.uniq
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def self.referencing_files(names, files, source_root)
|
|
182
|
+
pattern = /\b(?:#{names.map { |name| Regexp.escape(name) }.join('|')})\b/
|
|
183
|
+
files.select do |file|
|
|
184
|
+
path = File.join(source_root, file)
|
|
185
|
+
File.exist?(path) && File.read(path).match?(pattern)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def self.matches_any?(file, patterns)
|
|
190
|
+
patterns.to_a.any? do |pattern|
|
|
191
|
+
if pattern.include?('*')
|
|
192
|
+
File.fnmatch?(pattern, file, File::FNM_PATHNAME | File::FNM_EXTGLOB | File::FNM_DOTMATCH)
|
|
193
|
+
else
|
|
194
|
+
file == pattern || file.start_with?(pattern.end_with?('/') ? pattern : "#{pattern}/")
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Target/Class/test -> Class/test, the key the coverage map uses.
|
|
200
|
+
def self.test_key(id)
|
|
201
|
+
id.split('/').last(2).join('/')
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def self.run_all(all_tests, reason)
|
|
205
|
+
selection(all_tests, all_tests, [reason], run_all: true)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def self.selection(all_tests, tests, reasons, run_all: false)
|
|
209
|
+
order = all_tests.each_with_index.to_h
|
|
210
|
+
{ 'run_all' => run_all, 'tests' => tests.sort_by { |id| order.fetch(id) }, 'reasons' => reasons }
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
#####################################################
|
|
214
|
+
# @!group Documentation
|
|
215
|
+
#####################################################
|
|
216
|
+
|
|
217
|
+
def self.description
|
|
218
|
+
'Selects the tests a change can affect, using a per-test coverage map'
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def self.details
|
|
222
|
+
<<~DETAILS
|
|
223
|
+
Intersects the changed files with a coverage map from `build_test_coverage_map` (or any producer that
|
|
224
|
+
writes the same JSON) and returns the tests that executed any of them. Falls back to all tests whenever
|
|
225
|
+
it cannot prove a test is unaffected:
|
|
226
|
+
- the changed files or the map are unavailable, or the map is older than `max_map_age_days`;
|
|
227
|
+
- a changed file matches `run_all_paths` (test infrastructure, build settings, CI config);
|
|
228
|
+
- a changed file under `source_paths` is not in the map and is not code (e.g. resources).
|
|
229
|
+
A changed code file that is not in the map and was not added or deleted is declaration-only (coverage
|
|
230
|
+
lists only files with executable code): it selects the tests of the mapped files that reference the
|
|
231
|
+
types it declares.
|
|
232
|
+
A changed file under `test_paths` selects every test of the classes it declares, and tests that are not
|
|
233
|
+
in the map yet (added after it was built) always run. Files outside all paths are ignored.
|
|
234
|
+
With `hot_test_ratio`, a file that (nearly) every test runs selects one test per class instead of all tests;
|
|
235
|
+
use it only when a full run on the base branch catches what that sample misses.
|
|
236
|
+
DETAILS
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def self.return_value
|
|
240
|
+
'Hash with `run_all` (Boolean), `tests` (subset of all_tests, in their order) and `reasons` (Array of String).'
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def self.available_options
|
|
244
|
+
[
|
|
245
|
+
FastlaneCore::ConfigItem.new(
|
|
246
|
+
key: :all_tests,
|
|
247
|
+
description: 'Every test identifier that could run, e.g. Target/Class/test from retrieve_xctest_names',
|
|
248
|
+
type: Array
|
|
249
|
+
),
|
|
250
|
+
FastlaneCore::ConfigItem.new(
|
|
251
|
+
key: :coverage_map,
|
|
252
|
+
description: 'Path to the coverage map JSON',
|
|
253
|
+
optional: true
|
|
254
|
+
),
|
|
255
|
+
FastlaneCore::ConfigItem.new(
|
|
256
|
+
key: :coverage_map_artifact,
|
|
257
|
+
description: 'GitHub Actions artifact to download the map from when coverage_map does not exist',
|
|
258
|
+
optional: true
|
|
259
|
+
),
|
|
260
|
+
FastlaneCore::ConfigItem.new(
|
|
261
|
+
key: :coverage_map_branch,
|
|
262
|
+
description: 'Branch whose artifact to download',
|
|
263
|
+
default_value: 'develop'
|
|
264
|
+
),
|
|
265
|
+
FastlaneCore::ConfigItem.new(
|
|
266
|
+
env_name: 'GITHUB_REPOSITORY',
|
|
267
|
+
key: :github_repository,
|
|
268
|
+
description: 'Repository in "owner/repo" form, for downloading the artifact',
|
|
269
|
+
optional: true
|
|
270
|
+
),
|
|
271
|
+
FastlaneCore::ConfigItem.new(
|
|
272
|
+
key: :changed_files,
|
|
273
|
+
description: 'Changed file paths relative to source_root. When nil, they come from `git diff diff_base...diff_head`',
|
|
274
|
+
type: Array,
|
|
275
|
+
optional: true
|
|
276
|
+
),
|
|
277
|
+
FastlaneCore::ConfigItem.new(
|
|
278
|
+
key: :diff_base,
|
|
279
|
+
description: 'Git revision to diff against. Defaults to the first parent of a pull request merge commit',
|
|
280
|
+
optional: true
|
|
281
|
+
),
|
|
282
|
+
FastlaneCore::ConfigItem.new(
|
|
283
|
+
key: :diff_head,
|
|
284
|
+
description: 'Git revision with the changes',
|
|
285
|
+
default_value: 'HEAD'
|
|
286
|
+
),
|
|
287
|
+
FastlaneCore::ConfigItem.new(
|
|
288
|
+
key: :source_paths,
|
|
289
|
+
description: 'Path prefixes or globs of the code under test. Unmapped changes here run all tests',
|
|
290
|
+
type: Array,
|
|
291
|
+
default_value: []
|
|
292
|
+
),
|
|
293
|
+
FastlaneCore::ConfigItem.new(
|
|
294
|
+
key: :test_paths,
|
|
295
|
+
description: 'Path prefixes or globs of the test classes. A change here runs the tests of the classes in the file',
|
|
296
|
+
type: Array,
|
|
297
|
+
default_value: []
|
|
298
|
+
),
|
|
299
|
+
FastlaneCore::ConfigItem.new(
|
|
300
|
+
key: :ignore_paths,
|
|
301
|
+
description: 'Path prefixes or globs that never affect tests, e.g. code generation configs inside source_paths',
|
|
302
|
+
type: Array,
|
|
303
|
+
default_value: []
|
|
304
|
+
),
|
|
305
|
+
FastlaneCore::ConfigItem.new(
|
|
306
|
+
key: :run_all_paths,
|
|
307
|
+
description: 'Path prefixes or globs whose change runs all tests, e.g. test helpers, project files, CI config',
|
|
308
|
+
type: Array,
|
|
309
|
+
default_value: []
|
|
310
|
+
),
|
|
311
|
+
FastlaneCore::ConfigItem.new(
|
|
312
|
+
key: :ignored_diff_lines,
|
|
313
|
+
description: 'Per run_all path or glob, regexes of changed lines that do not affect tests, ' \
|
|
314
|
+
'e.g. file membership in project.pbxproj. The path runs all tests only if another line changed. ' \
|
|
315
|
+
'Needs the git diff, so it does not apply to changed_files. Defaults to target membership ' \
|
|
316
|
+
'edits in **/*.pbxproj; passing a hash replaces the default',
|
|
317
|
+
type: Hash,
|
|
318
|
+
default_value: { '**/*.pbxproj' => Helper::TestCoverageMapHelper::XCODE_PROJECT_IGNORED_LINES }
|
|
319
|
+
),
|
|
320
|
+
FastlaneCore::ConfigItem.new(
|
|
321
|
+
key: :hot_test_ratio,
|
|
322
|
+
description: 'When a changed file is run by at least this share of the mapped tests (e.g. 0.9), select one ' \
|
|
323
|
+
'test per class for it instead of all of them. nil selects every test that runs the file',
|
|
324
|
+
type: Float,
|
|
325
|
+
optional: true
|
|
326
|
+
),
|
|
327
|
+
FastlaneCore::ConfigItem.new(
|
|
328
|
+
key: :max_map_age_days,
|
|
329
|
+
description: 'Run all tests when the map is older than this',
|
|
330
|
+
type: Float,
|
|
331
|
+
default_value: 7.0
|
|
332
|
+
),
|
|
333
|
+
FastlaneCore::ConfigItem.new(
|
|
334
|
+
key: :source_root,
|
|
335
|
+
description: 'Repository root that changed file paths are relative to, the current directory by default ' \
|
|
336
|
+
'(fastlane runs actions from the repository root)',
|
|
337
|
+
optional: true
|
|
338
|
+
),
|
|
339
|
+
FastlaneCore::ConfigItem.new(
|
|
340
|
+
key: :output_path,
|
|
341
|
+
description: 'Where to write the selection as JSON',
|
|
342
|
+
optional: true
|
|
343
|
+
)
|
|
344
|
+
]
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def self.example_code
|
|
348
|
+
[
|
|
349
|
+
'selection = select_impacted_tests(
|
|
350
|
+
all_tests: retrieve_xctest_names(xctestrun: xctestrun).values.flatten,
|
|
351
|
+
coverage_map_artifact: "e2e-coverage-map",
|
|
352
|
+
source_paths: ["Sources"],
|
|
353
|
+
test_paths: ["UITests/Tests"],
|
|
354
|
+
run_all_paths: ["UITests/Robots", "Package.swift", ".github/workflows"]
|
|
355
|
+
)
|
|
356
|
+
scan(only_testing: selection["tests"]) unless selection["tests"].empty?'
|
|
357
|
+
]
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def self.category
|
|
361
|
+
:testing
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def self.is_supported?(platform)
|
|
365
|
+
true
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'open3'
|
|
3
|
+
require 'tmpdir'
|
|
4
|
+
|
|
5
|
+
module Fastlane
|
|
6
|
+
module Helper
|
|
7
|
+
# Reads, writes and downloads the per-test coverage maps of build_test_coverage_map and select_impacted_tests.
|
|
8
|
+
class TestCoverageMapHelper
|
|
9
|
+
# Changed project.pbxproj lines that only move files in and out of targets; the files themselves are diffed and
|
|
10
|
+
# mapped. List items (`ID /* Name */,`) also cover packageProductDependencies and target dependencies.
|
|
11
|
+
XCODE_PROJECT_IGNORED_LINES = [
|
|
12
|
+
'/\* .+ \*/,?$',
|
|
13
|
+
'/\* .+ \*/ = \{isa = PBX(BuildFile|FileReference); ',
|
|
14
|
+
'/\* Exceptions for .+ \*/ = \{$',
|
|
15
|
+
'^\s*isa = PBXFileSystemSynchronized\w*ExceptionSet;$',
|
|
16
|
+
'^\s*(buildPhase|target) = \w+ /\* .+ \*/;$',
|
|
17
|
+
'^\s*(children|exceptions|files|membershipExceptions) = \($',
|
|
18
|
+
'^\s*[^=]+\.\w+,$',
|
|
19
|
+
'^\s*\)?;?$',
|
|
20
|
+
'^\s*\};$'
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
MAP_VERSION = 1
|
|
24
|
+
|
|
25
|
+
# Downloads the newest unexpired artifact with this name produced on coverage_map_branch.
|
|
26
|
+
def self.download_map(params)
|
|
27
|
+
repo = params[:github_repository]
|
|
28
|
+
query = ".artifacts | map(select(.expired | not) | select(.workflow_run.head_branch == \"#{params[:coverage_map_branch]}\")) " \
|
|
29
|
+
'| sort_by(.created_at) | last | .workflow_run.id // empty'
|
|
30
|
+
run_id = command('gh', 'api', "repos/#{repo}/actions/artifacts?name=#{params[:coverage_map_artifact]}&per_page=100", '-q', query).strip
|
|
31
|
+
return nil if run_id.empty?
|
|
32
|
+
|
|
33
|
+
dir = Dir.mktmpdir('coverage-map')
|
|
34
|
+
command('gh', 'run', 'download', run_id, '--repo', repo, '--name', params[:coverage_map_artifact], '--dir', dir)
|
|
35
|
+
Dir.glob(File.join(dir, '**', '*.json')).first
|
|
36
|
+
rescue StandardError => e
|
|
37
|
+
UI.important("Could not download the coverage map: #{e.message}")
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.command(*args)
|
|
42
|
+
output, status = Open3.capture2e(*args)
|
|
43
|
+
raise "`#{args.join(' ')}` failed: #{output}" unless status.success?
|
|
44
|
+
|
|
45
|
+
output
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.read_map(path)
|
|
49
|
+
map = JSON.parse(File.read(path))
|
|
50
|
+
UI.user_error!("Unsupported coverage map version #{map['version']}") unless map['version'] == MAP_VERSION
|
|
51
|
+
|
|
52
|
+
sources = map['sources']
|
|
53
|
+
map.merge('tests' => map['tests'].transform_values { |indices| indices.map { |i| sources.fetch(i) } })
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: fastlane-plugin-stream_actions
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.4.
|
|
4
|
+
version: 0.4.12
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- GetStream
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-10-13 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: xctest_list
|
|
@@ -233,6 +233,7 @@ files:
|
|
|
233
233
|
- lib/fastlane/plugin/stream_actions/actions/allure_create_testcase.rb
|
|
234
234
|
- lib/fastlane/plugin/stream_actions/actions/allure_run_testplan.rb
|
|
235
235
|
- lib/fastlane/plugin/stream_actions/actions/build_app_for_ios_simulator.rb
|
|
236
|
+
- lib/fastlane/plugin/stream_actions/actions/build_test_coverage_map.rb
|
|
236
237
|
- lib/fastlane/plugin/stream_actions/actions/check_unsafe_flags.rb
|
|
237
238
|
- lib/fastlane/plugin/stream_actions/actions/core_version.rb
|
|
238
239
|
- lib/fastlane/plugin/stream_actions/actions/current_branch.rb
|
|
@@ -252,6 +253,7 @@ files:
|
|
|
252
253
|
- lib/fastlane/plugin/stream_actions/actions/read_changelog.rb
|
|
253
254
|
- lib/fastlane/plugin/stream_actions/actions/release_ios_sdk.rb
|
|
254
255
|
- lib/fastlane/plugin/stream_actions/actions/retrieve_xctest_names.rb
|
|
256
|
+
- lib/fastlane/plugin/stream_actions/actions/select_impacted_tests.rb
|
|
255
257
|
- lib/fastlane/plugin/stream_actions/actions/select_xcode.rb
|
|
256
258
|
- lib/fastlane/plugin/stream_actions/actions/setup_git_config.rb
|
|
257
259
|
- lib/fastlane/plugin/stream_actions/actions/show_detailed_sdk_size.rb
|
|
@@ -263,6 +265,7 @@ files:
|
|
|
263
265
|
- lib/fastlane/plugin/stream_actions/actions/update_sdk_size_in_readme.rb
|
|
264
266
|
- lib/fastlane/plugin/stream_actions/actions/update_testplan.rb
|
|
265
267
|
- lib/fastlane/plugin/stream_actions/actions/wait_android_emu_idle.rb
|
|
268
|
+
- lib/fastlane/plugin/stream_actions/helper/test_coverage_map_helper.rb
|
|
266
269
|
- lib/fastlane/plugin/stream_actions/version.rb
|
|
267
270
|
homepage: https://github.com/GetStream/fastlane-plugin-stream_actions
|
|
268
271
|
licenses: []
|