fastlane-plugin-stream_actions 0.4.10 → 0.4.11

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2401c759c842685fbbb27e25fba273091eff05627873dc3f245a2692ac729986
4
- data.tar.gz: 535b9fdb380ecae53c5ca3c39f69ad5f70ee481d0c8c65f088b3992914e53efa
3
+ metadata.gz: 173ef91c158a9657e7c37ef7bb26930b9ddc0f7d54752d6854bab52add33e646
4
+ data.tar.gz: 055ea6b195476bdb4364b225e3b1436ba139792bcc7db994ce6e26b3e35f0dca
5
5
  SHA512:
6
- metadata.gz: a2b45995454c839f0451745d3ca98da00f96ac09f862bc20669b96cf2c4ebf87973041bc2fbf4b8f16cd417d3db8b6abbbc61e6d75a88e4440f8b008804bff75
7
- data.tar.gz: e1a9de8fdf353ff9138031d5ee066004942e86de5da07d6a159496919c4d34592a0b7a0f73d5bfb7b43caabf9971cd6a8428358a2e676c3365fa0af26bc85960
6
+ metadata.gz: e6cf26760cf83d07877a5d3b142fe95e15339c547751c9c2eaa281750d99d47c3fefedbfac41110efc6096fc538240429e721c6ce2e61610641c21ce7344fd44
7
+ data.tar.gz: 1855f9eb4c7dbd2db7daa3cc2aedaeba547d42419d180bc2531bc770c9ead7e89727069e9fba920d463f0dd595396fad1c4e6b09d7efecc9930c421865aedb46
@@ -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,368 @@
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',
316
+ type: Hash,
317
+ default_value: {}
318
+ ),
319
+ FastlaneCore::ConfigItem.new(
320
+ key: :hot_test_ratio,
321
+ description: 'When a changed file is run by at least this share of the mapped tests (e.g. 0.9), select one ' \
322
+ 'test per class for it instead of all of them. nil selects every test that runs the file',
323
+ type: Float,
324
+ optional: true
325
+ ),
326
+ FastlaneCore::ConfigItem.new(
327
+ key: :max_map_age_days,
328
+ description: 'Run all tests when the map is older than this',
329
+ type: Float,
330
+ default_value: 7.0
331
+ ),
332
+ FastlaneCore::ConfigItem.new(
333
+ key: :source_root,
334
+ description: 'Repository root that changed file paths are relative to, the current directory by default ' \
335
+ '(fastlane runs actions from the repository root)',
336
+ optional: true
337
+ ),
338
+ FastlaneCore::ConfigItem.new(
339
+ key: :output_path,
340
+ description: 'Where to write the selection as JSON',
341
+ optional: true
342
+ )
343
+ ]
344
+ end
345
+
346
+ def self.example_code
347
+ [
348
+ 'selection = select_impacted_tests(
349
+ all_tests: retrieve_xctest_names(xctestrun: xctestrun).values.flatten,
350
+ coverage_map_artifact: "e2e-coverage-map",
351
+ source_paths: ["Sources"],
352
+ test_paths: ["UITests/Tests"],
353
+ run_all_paths: ["UITests/Robots", "Package.swift", ".github/workflows"]
354
+ )
355
+ scan(only_testing: selection["tests"]) unless selection["tests"].empty?'
356
+ ]
357
+ end
358
+
359
+ def self.category
360
+ :testing
361
+ end
362
+
363
+ def self.is_supported?(platform)
364
+ true
365
+ end
366
+ end
367
+ end
368
+ end
@@ -0,0 +1,43 @@
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
+ MAP_VERSION = 1
10
+
11
+ # Downloads the newest unexpired artifact with this name produced on coverage_map_branch.
12
+ def self.download_map(params)
13
+ repo = params[:github_repository]
14
+ query = ".artifacts | map(select(.expired | not) | select(.workflow_run.head_branch == \"#{params[:coverage_map_branch]}\")) " \
15
+ '| sort_by(.created_at) | last | .workflow_run.id // empty'
16
+ run_id = command('gh', 'api', "repos/#{repo}/actions/artifacts?name=#{params[:coverage_map_artifact]}&per_page=100", '-q', query).strip
17
+ return nil if run_id.empty?
18
+
19
+ dir = Dir.mktmpdir('coverage-map')
20
+ command('gh', 'run', 'download', run_id, '--repo', repo, '--name', params[:coverage_map_artifact], '--dir', dir)
21
+ Dir.glob(File.join(dir, '**', '*.json')).first
22
+ rescue StandardError => e
23
+ UI.important("Could not download the coverage map: #{e.message}")
24
+ nil
25
+ end
26
+
27
+ def self.command(*args)
28
+ output, status = Open3.capture2e(*args)
29
+ raise "`#{args.join(' ')}` failed: #{output}" unless status.success?
30
+
31
+ output
32
+ end
33
+
34
+ def self.read_map(path)
35
+ map = JSON.parse(File.read(path))
36
+ UI.user_error!("Unsupported coverage map version #{map['version']}") unless map['version'] == MAP_VERSION
37
+
38
+ sources = map['sources']
39
+ map.merge('tests' => map['tests'].transform_values { |indices| indices.map { |i| sources.fetch(i) } })
40
+ end
41
+ end
42
+ end
43
+ end
@@ -1,5 +1,5 @@
1
1
  module Fastlane
2
2
  module StreamActions
3
- VERSION = '0.4.10'
3
+ VERSION = '0.4.11'
4
4
  end
5
5
  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.10
4
+ version: 0.4.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - GetStream
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-18 00:00:00.000000000 Z
11
+ date: 2026-10-12 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: []