fastlane-plugin-stream_actions 0.4.11 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 173ef91c158a9657e7c37ef7bb26930b9ddc0f7d54752d6854bab52add33e646
4
- data.tar.gz: 055ea6b195476bdb4364b225e3b1436ba139792bcc7db994ce6e26b3e35f0dca
3
+ metadata.gz: 637f0d12b17b61c738e37e6530202c347d45776ec6281591c1b6ea94e162d501
4
+ data.tar.gz: fc3494f5e33aa0e6cde00c3bb741880e709338f670742a4a1697774f8fcd36af
5
5
  SHA512:
6
- metadata.gz: e6cf26760cf83d07877a5d3b142fe95e15339c547751c9c2eaa281750d99d47c3fefedbfac41110efc6096fc538240429e721c6ce2e61610641c21ce7344fd44
7
- data.tar.gz: 1855f9eb4c7dbd2db7daa3cc2aedaeba547d42419d180bc2531bc770c9ead7e89727069e9fba920d463f0dd595396fad1c4e6b09d7efecc9930c421865aedb46
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
- 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
  ),
@@ -312,9 +328,10 @@ module Fastlane
312
328
  key: :ignored_diff_lines,
313
329
  description: 'Per run_all path or glob, regexes of changed lines that do not affect tests, ' \
314
330
  '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',
331
+ 'Needs the git diff, so it does not apply to changed_files. Defaults to target membership ' \
332
+ 'edits in **/*.pbxproj; passing a hash replaces the default',
316
333
  type: Hash,
317
- default_value: {}
334
+ default_value: { '**/*.pbxproj' => Helper::TestCoverageMapHelper::XCODE_PROJECT_IGNORED_LINES }
318
335
  ),
319
336
  FastlaneCore::ConfigItem.new(
320
337
  key: :hot_test_ratio,
@@ -1,11 +1,26 @@
1
1
  require 'json'
2
2
  require 'open3'
3
+ require 'time'
3
4
  require 'tmpdir'
4
5
 
5
6
  module Fastlane
6
7
  module Helper
7
8
  # Reads, writes and downloads the per-test coverage maps of build_test_coverage_map and select_impacted_tests.
8
9
  class TestCoverageMapHelper
10
+ # Changed project.pbxproj lines that only move files in and out of targets; the files themselves are diffed and
11
+ # mapped. List items (`ID /* Name */,`) also cover packageProductDependencies and target dependencies.
12
+ XCODE_PROJECT_IGNORED_LINES = [
13
+ '/\* .+ \*/,?$',
14
+ '/\* .+ \*/ = \{isa = PBX(BuildFile|FileReference); ',
15
+ '/\* Exceptions for .+ \*/ = \{$',
16
+ '^\s*isa = PBXFileSystemSynchronized\w*ExceptionSet;$',
17
+ '^\s*(buildPhase|target) = \w+ /\* .+ \*/;$',
18
+ '^\s*(children|exceptions|files|membershipExceptions) = \($',
19
+ '^\s*[^=]+\.\w+,$',
20
+ '^\s*\)?;?$',
21
+ '^\s*\};$'
22
+ ].freeze
23
+
9
24
  MAP_VERSION = 1
10
25
 
11
26
  # Downloads the newest unexpired artifact with this name produced on coverage_map_branch.
@@ -31,12 +46,28 @@ module Fastlane
31
46
  output
32
47
  end
33
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
+
34
61
  def self.read_map(path)
35
62
  map = JSON.parse(File.read(path))
36
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)
37
65
 
38
66
  sources = map['sources']
39
- 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
+ )
40
71
  end
41
72
  end
42
73
  end
@@ -1,5 +1,5 @@
1
1
  module Fastlane
2
2
  module StreamActions
3
- VERSION = '0.4.11'
3
+ VERSION = '0.4.13'
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.11
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-12 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