fastlane-plugin-stream_actions 0.4.12 → 0.4.13
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 +26 -13
- data/lib/fastlane/plugin/stream_actions/actions/select_impacted_tests.rb +40 -24
- data/lib/fastlane/plugin/stream_actions/helper/test_coverage_map_helper.rb +18 -1
- data/lib/fastlane/plugin/stream_actions/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 637f0d12b17b61c738e37e6530202c347d45776ec6281591c1b6ea94e162d501
|
|
4
|
+
data.tar.gz: fc3494f5e33aa0e6cde00c3bb741880e709338f670742a4a1697774f8fcd36af
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9fe31f2ee1e876d9bab6e7e434d06b74f9de6f9bf70124afda534638d8c6eb96e88c49f06c05669f432bdcfdbcf6024e4317cf4646202d53c78e82c2d58093a1
|
|
7
|
+
data.tar.gz: df42ca37cabdb7c533379f407aa3dfd21898214eb1206ca61cfad635f082562e91cc9bd35153ba06c7f736181151369c06e8b7aafd80df7a3a1bd2e118742ece
|
|
@@ -89,8 +89,26 @@ module Fastlane
|
|
|
89
89
|
}
|
|
90
90
|
end
|
|
91
91
|
|
|
92
|
-
# A failed test stops early, so its fresh coverage can miss code it normally runs.
|
|
92
|
+
# A failed test stops early, so its fresh coverage can miss code it normally runs. Without its previous
|
|
93
|
+
# coverage the test leaves the map, and select_impacted_tests runs tests that are not in the map every time.
|
|
93
94
|
def self.carry_over_failed_tests(tests, sources, params)
|
|
95
|
+
previous = previous_map(params)
|
|
96
|
+
params[:failed_tests].to_a.each do |failed_test|
|
|
97
|
+
test = failed_test.split('/').last(2).join('/')
|
|
98
|
+
previous_files = previous && previous['tests'][test]
|
|
99
|
+
if previous_files.nil?
|
|
100
|
+
tests.delete(test)
|
|
101
|
+
UI.important("No previous coverage of the failed #{test}, so it runs on every pull request until it passes")
|
|
102
|
+
next
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
tests[test] = (tests[test].to_a | previous_files)
|
|
106
|
+
sources.concat(previous_files - sources)
|
|
107
|
+
UI.message("Kept the previous coverage of the failed #{test}")
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def self.previous_map(params)
|
|
94
112
|
path = params[:previous_map]
|
|
95
113
|
if path.nil? && params[:previous_map_artifact]
|
|
96
114
|
path = Helper::TestCoverageMapHelper.download_map(
|
|
@@ -99,18 +117,12 @@ module Fastlane
|
|
|
99
117
|
github_repository: params[:github_repository]
|
|
100
118
|
)
|
|
101
119
|
end
|
|
102
|
-
return if path.nil? || !File.exist?(path)
|
|
120
|
+
return nil if path.nil? || !File.exist?(path)
|
|
103
121
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
122
|
+
Helper::TestCoverageMapHelper.read_map(path)
|
|
123
|
+
rescue StandardError => e
|
|
124
|
+
UI.important("Could not read the previous coverage map: #{e.message}")
|
|
125
|
+
nil
|
|
114
126
|
end
|
|
115
127
|
|
|
116
128
|
# Keeps sources the repository tracks, dropping e.g. package checkouts cloned inside it.
|
|
@@ -207,7 +219,8 @@ module Fastlane
|
|
|
207
219
|
),
|
|
208
220
|
FastlaneCore::ConfigItem.new(
|
|
209
221
|
key: :failed_tests,
|
|
210
|
-
description: 'Tests that failed in this run, as Class/test or Target/Class/test'
|
|
222
|
+
description: 'Tests that failed in this run, as Class/test or Target/Class/test. Each takes its previous coverage, ' \
|
|
223
|
+
'or leaves the map when there is none',
|
|
211
224
|
type: Array,
|
|
212
225
|
optional: true,
|
|
213
226
|
default_value: []
|
|
@@ -7,7 +7,7 @@ module Fastlane
|
|
|
7
7
|
class SelectImpactedTestsAction < Action
|
|
8
8
|
CODE_EXTENSIONS = %w[.swift .m .mm .h .kt .java .dart .js .jsx .ts .tsx].freeze
|
|
9
9
|
MODIFIERS = '(?:(?:public|open|internal|fileprivate|private|final|indirect|nonisolated|data|sealed|abstract|export|default)\\s+)*'
|
|
10
|
-
DECLARATION = /^\s*(?:@\w+(?:\([^)\n]*\))?\s+)*#{MODIFIERS}(
|
|
10
|
+
DECLARATION = /^\s*(?:@\w+(?:\([^)\n]*\))?\s+)*#{MODIFIERS}(class|struct|enum|protocol|actor|typealias|extension|interface|object)\s+([A-Za-z_]\w*)/.freeze
|
|
11
11
|
|
|
12
12
|
def self.run(params)
|
|
13
13
|
source_root = File.expand_path(params[:source_root] || Dir.pwd)
|
|
@@ -35,7 +35,7 @@ module Fastlane
|
|
|
35
35
|
map = load_map(params)
|
|
36
36
|
return run_all(all_tests, 'no coverage map') if map.nil?
|
|
37
37
|
|
|
38
|
-
age_days = (Time.now.utc -
|
|
38
|
+
age_days = (Time.now.utc - map['generated_at']) / 86_400
|
|
39
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
40
|
|
|
41
41
|
tests_by_file = Hash.new { |hash, key| hash[key] = [] }
|
|
@@ -60,18 +60,23 @@ module Fastlane
|
|
|
60
60
|
elsif matches_any?(file, params[:source_paths])
|
|
61
61
|
return run_all(all_tests, "#{file} is not in the coverage map") unless CODE_EXTENSIONS.include?(File.extname(file))
|
|
62
62
|
|
|
63
|
-
if status == '
|
|
64
|
-
reasons << "#{file}:
|
|
63
|
+
if status == 'D'
|
|
64
|
+
reasons << "#{file}: deleted, so the files using it changed too"
|
|
65
65
|
next
|
|
66
66
|
end
|
|
67
67
|
|
|
68
68
|
# Declaration-only files (models, protocols, constants) have no executable regions, so coverage
|
|
69
69
|
# never lists them. Their declarations still matter, e.g. a Codable key, so run the tests of the
|
|
70
70
|
# mapped files that reference the declared types.
|
|
71
|
-
names = declared_names(file, source_root, params)
|
|
71
|
+
names = declared_names(file, source_root, params, added: status == 'A')
|
|
72
|
+
users = names.empty? ? [] : referencing_files(names, known_sources - [file], source_root, params)
|
|
73
|
+
if status == 'A' && users.empty?
|
|
74
|
+
reasons << "#{file}: added, so the files using it changed too"
|
|
75
|
+
next
|
|
76
|
+
end
|
|
72
77
|
return run_all(all_tests, "#{file} is not in the coverage map and declares no types") if names.empty?
|
|
78
|
+
return run_all(all_tests, "#{file} is not in the coverage map and no mapped file uses #{names.join(', ')}") if users.empty?
|
|
73
79
|
|
|
74
|
-
users = referencing_files(names, known_sources - [file], source_root)
|
|
75
80
|
tests = users.flat_map { |user| tests_by_file[user] }.uniq
|
|
76
81
|
selected.concat(impacted(tests, all_tests, mapped_count, params))
|
|
77
82
|
reasons << "#{file}: #{describe(tests, mapped_count, params)} of the #{users.size} mapped files using #{names.join(', ')}"
|
|
@@ -125,8 +130,9 @@ module Fastlane
|
|
|
125
130
|
|
|
126
131
|
regexes = patterns.map { |pattern| Regexp.new(pattern) }
|
|
127
132
|
lines = Helper::TestCoverageMapHelper.command('git', 'diff', '-U0', diff_range(params), '--', file).lines.map(&:chomp)
|
|
128
|
-
|
|
129
|
-
changed
|
|
133
|
+
hunks = lines.drop_while { |line| !line.start_with?('@@') }
|
|
134
|
+
changed = hunks.grep(/^[+-]/).map { |line| line[1..-1] }
|
|
135
|
+
!changed.empty? && changed.all? { |line| regexes.any? { |regex| line.match?(regex) } }
|
|
130
136
|
rescue StandardError => e
|
|
131
137
|
UI.important("Could not read the diff of #{file}: #{e.message}")
|
|
132
138
|
false
|
|
@@ -145,49 +151,56 @@ module Fastlane
|
|
|
145
151
|
|
|
146
152
|
def self.test_classes(file, source_root)
|
|
147
153
|
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 : []
|
|
154
|
+
classes = File.exist?(path) ? File.read(path).scan(/^\s*(?:@\w+(?:\([^)\n]*\))?\s+)*(?:(?:public|open|final|internal)\s+)*(?:class|extension)\s+(\w+)/).flatten : []
|
|
149
155
|
classes.empty? ? [File.basename(file, '.*')] : classes
|
|
150
156
|
end
|
|
151
157
|
|
|
152
158
|
# Code nearly every test runs (app start, login, the main screen) would select everything. With
|
|
153
159
|
# hot_test_ratio such a change runs one test per class instead, leaving the rest to the full nightly run.
|
|
154
160
|
def self.impacted(tests, all_tests, mapped_count, params)
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
all_tests.select { |id| tests.include?(test_key(id)) }
|
|
161
|
+
running = all_tests.select { |id| tests.include?(test_key(id)) }
|
|
162
|
+
hot?(tests, mapped_count, params) ? smoke_tests(running) : running
|
|
158
163
|
end
|
|
159
164
|
|
|
160
165
|
def self.hot?(tests, mapped_count, params)
|
|
161
166
|
params[:hot_test_ratio] && mapped_count.positive? && tests.size >= params[:hot_test_ratio] * mapped_count
|
|
162
167
|
end
|
|
163
168
|
|
|
164
|
-
def self.smoke_tests(
|
|
165
|
-
|
|
169
|
+
def self.smoke_tests(tests)
|
|
170
|
+
tests.group_by { |id| test_key(id).split('/').first }.values.map(&:first)
|
|
166
171
|
end
|
|
167
172
|
|
|
168
173
|
def self.describe(tests, mapped_count, params)
|
|
169
174
|
hot?(tests, mapped_count, params) ? "run by #{tests.size} tests, so one test per class" : "#{tests.size} tests"
|
|
170
175
|
end
|
|
171
176
|
|
|
172
|
-
|
|
177
|
+
# A new type does nothing until a changed file uses it, so an added file counts only the existing types it extends.
|
|
178
|
+
def self.declared_names(file, source_root, params, added: false)
|
|
173
179
|
content = begin
|
|
174
180
|
params[:changed_files] ? File.read(File.join(source_root, file)) : Helper::TestCoverageMapHelper.command('git', 'show', "#{params[:diff_head]}:#{file}")
|
|
175
181
|
rescue StandardError
|
|
176
182
|
''
|
|
177
183
|
end
|
|
178
|
-
content.scan(DECLARATION).
|
|
184
|
+
extended, declared = content.scan(DECLARATION).partition { |kind, _| kind == 'extension' }.map { |list| list.map(&:last).uniq }
|
|
185
|
+
added ? extended - declared : (declared + extended).uniq
|
|
179
186
|
end
|
|
180
187
|
|
|
181
|
-
|
|
188
|
+
# Reads the files at diff_head, like declared_names, so the lookup does not depend on what is checked out.
|
|
189
|
+
def self.referencing_files(names, files, source_root, params)
|
|
190
|
+
return Helper::TestCoverageMapHelper.files_containing(names, params[:diff_head], files) unless params[:changed_files]
|
|
191
|
+
|
|
182
192
|
pattern = /\b(?:#{names.map { |name| Regexp.escape(name) }.join('|')})\b/
|
|
183
193
|
files.select do |file|
|
|
184
194
|
path = File.join(source_root, file)
|
|
185
195
|
File.exist?(path) && File.read(path).match?(pattern)
|
|
186
196
|
end
|
|
197
|
+
rescue StandardError => e
|
|
198
|
+
UI.important("Could not search for #{names.join(', ')}: #{e.message}")
|
|
199
|
+
[]
|
|
187
200
|
end
|
|
188
201
|
|
|
189
202
|
def self.matches_any?(file, patterns)
|
|
190
|
-
patterns.to_a.any? do |pattern|
|
|
203
|
+
patterns.to_a.map(&:to_s).any? do |pattern|
|
|
191
204
|
if pattern.include?('*')
|
|
192
205
|
File.fnmatch?(pattern, file, File::FNM_PATHNAME | File::FNM_EXTGLOB | File::FNM_DOTMATCH)
|
|
193
206
|
else
|
|
@@ -225,12 +238,14 @@ module Fastlane
|
|
|
225
238
|
it cannot prove a test is unaffected:
|
|
226
239
|
- the changed files or the map are unavailable, or the map is older than `max_map_age_days`;
|
|
227
240
|
- 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
|
-
|
|
230
|
-
|
|
231
|
-
types it declares.
|
|
232
|
-
|
|
241
|
+
- a changed file under `source_paths` is not in the map and is not code (e.g. resources), or is a
|
|
242
|
+
declaration-only code file that no mapped file references.
|
|
243
|
+
A modified code file that is not in the map is declaration-only (coverage lists only files with executable
|
|
244
|
+
code): it selects the tests of the mapped files that reference the types it declares. An added file only counts
|
|
245
|
+
the existing types it extends, as its new types run through the changed files using them.
|
|
246
|
+
A changed file under `test_paths` selects every test of the classes it declares or extends, and tests that are not
|
|
233
247
|
in the map yet (added after it was built) always run. Files outside all paths are ignored.
|
|
248
|
+
A changed file is matched against run_all_paths first, then ignore_paths, then test_paths, then the map.
|
|
234
249
|
With `hot_test_ratio`, a file that (nearly) every test runs selects one test per class instead of all tests;
|
|
235
250
|
use it only when a full run on the base branch catches what that sample misses.
|
|
236
251
|
DETAILS
|
|
@@ -298,7 +313,8 @@ module Fastlane
|
|
|
298
313
|
),
|
|
299
314
|
FastlaneCore::ConfigItem.new(
|
|
300
315
|
key: :ignore_paths,
|
|
301
|
-
description: 'Path prefixes or globs that
|
|
316
|
+
description: 'Path prefixes or globs that do not affect tests, e.g. code generation configs inside source_paths. ' \
|
|
317
|
+
'run_all_paths takes precedence, so a run_all file inside an ignored glob still runs all tests',
|
|
302
318
|
type: Array,
|
|
303
319
|
default_value: []
|
|
304
320
|
),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require 'json'
|
|
2
2
|
require 'open3'
|
|
3
|
+
require 'time'
|
|
3
4
|
require 'tmpdir'
|
|
4
5
|
|
|
5
6
|
module Fastlane
|
|
@@ -45,12 +46,28 @@ module Fastlane
|
|
|
45
46
|
output
|
|
46
47
|
end
|
|
47
48
|
|
|
49
|
+
# Files of the list that contain one of the words at the given revision.
|
|
50
|
+
def self.files_containing(words, revision, files)
|
|
51
|
+
patterns = words.flat_map { |word| ['-e', word] }
|
|
52
|
+
prefix = /\A#{Regexp.escape(revision)}:/
|
|
53
|
+
files.each_slice(500).flat_map do |slice|
|
|
54
|
+
output, status = Open3.capture2e('git', 'grep', '-l', '-w', '-F', *patterns, revision, '--', *slice)
|
|
55
|
+
raise "`git grep` failed: #{output}" unless status.success? || status.exitstatus == 1
|
|
56
|
+
|
|
57
|
+
output.split("\n").map { |line| line.sub(prefix, '') }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
48
61
|
def self.read_map(path)
|
|
49
62
|
map = JSON.parse(File.read(path))
|
|
50
63
|
UI.user_error!("Unsupported coverage map version #{map['version']}") unless map['version'] == MAP_VERSION
|
|
64
|
+
generated_at = Time.iso8601(map['generated_at'].to_s)
|
|
51
65
|
|
|
52
66
|
sources = map['sources']
|
|
53
|
-
map.merge(
|
|
67
|
+
map.merge(
|
|
68
|
+
'generated_at' => generated_at,
|
|
69
|
+
'tests' => map['tests'].transform_values { |indices| indices.map { |i| sources.fetch(i) } }
|
|
70
|
+
)
|
|
54
71
|
end
|
|
55
72
|
end
|
|
56
73
|
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.13
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- GetStream
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-14 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: xctest_list
|