fastlane-plugin-stream_actions 0.4.12 → 0.4.14

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: 7e20315e989110210371734616854eb1cf055d2318fe8c1c7031ee5642eb95cf
4
- data.tar.gz: 87fab4f22f00b8771fdede017075a4df012942c24baae580613313e781e55745
3
+ metadata.gz: ea8c0b10a707aea52e96abb115d761a08518379e8030df3f2f6ea8def3cf089f
4
+ data.tar.gz: 2cb6310cc1474349e591a1967efa8e5ba980fb095fe280eb13db43f5d78ca3ef
5
5
  SHA512:
6
- metadata.gz: cf84b704871864755de8317c4e0e9aef2ffd38275f6a8ab8903e3e1f7289c7fdf7563e77aed2aa27f2a61e26621f84c9a9587d659683ae86b1b8550134ec9288
7
- data.tar.gz: e784f17eb5d31bb15d53209d69e58135a07f7d87d3a6ea03461ca8b627e63da6cda99b80655a97f62bba9417e8d7c49496edc8c61314ada0eeca797808ed4fa5
6
+ metadata.gz: 19f9d221bdb58b9ba68276cb9aea3049003bd0d25b7c62ee8fb02917c18cc7e6099cf34017520f0e26d2df0dcba7f731d15ce7751cc5842ba22757599d66b63f
7
+ data.tar.gz: 28562ca0b51a398fa1c8117c847f2f3c1dab3b481b127a6f16b7718753d6fad4673fe37894393a04901e31d37ef295998c3849a25f0272a97e0809913535eddc
@@ -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
- 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
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}(?:class|struct|enum|protocol|actor|typealias|extension|interface|object)\s+([A-Za-z_]\w*)/.freeze
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 - Time.parse(map['generated_at'])) / 86_400
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 == 'A' || status == 'D'
64
- reasons << "#{file}: #{status == 'A' ? 'added' : 'deleted'}, so the files using it changed too"
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
- changed = lines.select { |line| line.match?(/^[+-]/) && !line.match?(/^(\+\+\+|---) /) }.map { |line| line[1..-1] }
129
- changed.all? { |line| regexes.any? { |regex| line.match?(regex) } }
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
- return smoke_tests(all_tests) if hot?(tests, mapped_count, params)
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(all_tests)
165
- all_tests.group_by { |id| test_key(id).split('/').first }.values.map(&:first)
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
- def self.declared_names(file, source_root, params)
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).flatten.uniq
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
- def self.referencing_files(names, files, source_root)
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
- 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
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 never affect tests, e.g. code generation configs inside source_paths',
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
  ),
@@ -6,6 +6,7 @@ module Fastlane
6
6
  fail_status = '🔴' # Failure if a branch is more than #{max_tolerance} less performant than the benchmark
7
7
  success_status = '🟢' # Success if a branch is more performant or equals to the benchmark
8
8
  outstanding_status = '🚀' # Outstanding performance
9
+ new_status = '🆕' # No benchmark yet, e.g. a newly tracked size
9
10
 
10
11
  metrics_dir = 'metrics'
11
12
  FileUtils.remove_dir(metrics_dir, force: true)
@@ -23,7 +24,13 @@ module Fastlane
23
24
  params[:branch_sizes].each do |sdk_name, branch_value_kb|
24
25
  branch_value_mb = (branch_value_kb / 1024.0).round(2)
25
26
  branch_value = is_kb ? branch_value_kb.round(0) : branch_value_mb
26
- benchmark_value_kb = benchmark_sizes[sdk_name.to_s] || 0
27
+ benchmark_value_kb = benchmark_sizes[sdk_name.to_s]
28
+ # A size the benchmark doesn't track yet has nothing to compare against. Only a push to the base
29
+ # branch or a release branch run rewrites the benchmark (see below), so PRs show it as new until then.
30
+ if benchmark_value_kb.nil?
31
+ markdown_table << "|#{sdk_name}|n/a|#{branch_value} #{params[:size_ext]}|n/a|#{new_status}|\n"
32
+ next
33
+ end
27
34
  benchmark_value_mb = (benchmark_value_kb / 1024.0).round(2)
28
35
  benchmark_value = is_kb ? benchmark_value_kb.round(0) : benchmark_value_mb
29
36
 
@@ -32,8 +39,8 @@ module Fastlane
32
39
  diff = is_kb ? diff_b : diff_kb
33
40
  diff_ext = is_kb ? 'B' : 'KB'
34
41
 
35
- max_tolerance = params[:max_tolerance] || diff_ext == 'B' ? 5000 : 500 # By default, Max Tolerance is 5000 Bytes or 500 Kilobytes
36
- fine_tolerance = params[:fine_tolerance] || diff_ext == 'B' ? 2500 : 250 # By default, Fine Tolerance is 2500 Bytes or 250 Kilobytes
42
+ max_tolerance = params[:max_tolerance] || (diff_ext == 'B' ? 5000 : 500) # By default, Max Tolerance is 5000 Bytes or 500 Kilobytes
43
+ fine_tolerance = params[:fine_tolerance] || (diff_ext == 'B' ? 2500 : 250) # By default, Fine Tolerance is 2500 Bytes or 250 Kilobytes
37
44
 
38
45
  diff_sign = if diff.zero?
39
46
  ''
@@ -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('tests' => map['tests'].transform_values { |indices| indices.map { |i| sources.fetch(i) } })
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
@@ -1,5 +1,5 @@
1
1
  module Fastlane
2
2
  module StreamActions
3
- VERSION = '0.4.12'
3
+ VERSION = '0.4.14'
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.12
4
+ version: 0.4.14
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-13 00:00:00.000000000 Z
11
+ date: 2026-10-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: xctest_list
@@ -44,14 +44,14 @@ dependencies:
44
44
  requirements:
45
45
  - - '='
46
46
  - !ruby/object:Gem::Version
47
- version: 0.9.0
47
+ version: 0.11.0
48
48
  type: :development
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
52
  - - '='
53
53
  - !ruby/object:Gem::Version
54
- version: 0.9.0
54
+ version: 0.11.0
55
55
  - !ruby/object:Gem::Dependency
56
56
  name: fastlane
57
57
  requirement: !ruby/object:Gem::Requirement