fastlane-plugin-stream_actions 0.4.14 → 0.4.16

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: ea8c0b10a707aea52e96abb115d761a08518379e8030df3f2f6ea8def3cf089f
4
- data.tar.gz: 2cb6310cc1474349e591a1967efa8e5ba980fb095fe280eb13db43f5d78ca3ef
3
+ metadata.gz: 026ec8f8ee8ff9ab781bd947a48ce316225ca8f933f86e1870e095cd44c1ab18
4
+ data.tar.gz: 83ed49f3b21c9ca683823b56c8e71b067211d01de73a5f9fffcf41b27c03f02f
5
5
  SHA512:
6
- metadata.gz: 19f9d221bdb58b9ba68276cb9aea3049003bd0d25b7c62ee8fb02917c18cc7e6099cf34017520f0e26d2df0dcba7f731d15ce7751cc5842ba22757599d66b63f
7
- data.tar.gz: 28562ca0b51a398fa1c8117c847f2f3c1dab3b481b127a6f16b7718753d6fad4673fe37894393a04901e31d37ef295998c3849a25f0272a97e0809913535eddc
6
+ metadata.gz: 3c73fcd00f53e169c3708a7f0205528175806fc739385f99a1ed3a5ccf0c90e7b25f218f556bced3bf3c54c59f8cf887e7200e492249f109b29f6e312d72c98f
7
+ data.tar.gz: c0bd2912bd7414870a310a4b20236b548adb0a35f23d05efa7e731052087cfe2549583d5c437ca70eff6346938a4248596cc9cd62e52ab49b24eb1e41128af1b
@@ -2,12 +2,39 @@ require 'json'
2
2
  require 'open3'
3
3
  require 'set'
4
4
  require 'time'
5
+ require 'tmpdir'
5
6
 
6
7
  module Fastlane
7
8
  module Actions
8
9
  class BuildTestCoverageMapAction < Action
9
10
  def self.run(params)
10
11
  source_root = File.expand_path(params[:source_root] || Dir.pwd)
12
+ sources, tests =
13
+ if params[:class_dirs]
14
+ jacoco_coverage(params, source_root)
15
+ elsif params[:binaries]
16
+ llvm_coverage(params, source_root)
17
+ else
18
+ lcov_coverage(params, source_root)
19
+ end
20
+
21
+ carry_over_failed_tests(tests, sources, params) unless params[:failed_tests].to_a.empty?
22
+ drop_untracked(tests, sources, source_root)
23
+
24
+ map = {
25
+ 'version' => Helper::TestCoverageMapHelper::MAP_VERSION,
26
+ 'generated_at' => Time.now.utc.iso8601,
27
+ 'commit' => params[:commit],
28
+ 'sources' => sources.sort,
29
+ 'tests' => tests.sort.to_h
30
+ }
31
+ write(map, params[:output_path])
32
+ UI.success("Coverage map of #{tests.size} tests and #{map['sources'].size} sources saved to #{params[:output_path]}")
33
+ map
34
+ end
35
+
36
+ # iOS: one folder of .profraw files per test, `<coverage_dir>/<TestClass>/<testMethod>/`.
37
+ def self.llvm_coverage(params, source_root)
11
38
  test_dirs = Dir.glob(File.join(params[:coverage_dir], '*', '*')).select { |dir| File.directory?(dir) }
12
39
  UI.user_error!("No per-test coverage found in '#{params[:coverage_dir]}'") if test_dirs.empty?
13
40
 
@@ -41,20 +68,99 @@ module Fastlane
41
68
  end
42
69
  end
43
70
  workers.each(&:join)
71
+ [sources, tests]
72
+ end
44
73
 
45
- carry_over_failed_tests(tests, sources, params) unless params[:failed_tests].to_a.empty?
46
- drop_untracked(tests, sources, source_root)
74
+ # Android: one JaCoCo `.ec` file per test, named `<package>.<TestClass>#<testMethod>.ec` by the test orchestrator.
75
+ def self.jacoco_coverage(params, source_root)
76
+ exec_files = Dir.glob(File.join(params[:coverage_dir], '**', '*.ec'))
77
+ UI.user_error!("No per-test coverage found in '#{params[:coverage_dir]}'") if exec_files.empty?
47
78
 
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
79
+ class_dirs = params[:class_dirs].select { |dir| File.directory?(dir) }
80
+ UI.user_error!('None of the class_dirs exist') if class_dirs.empty?
81
+
82
+ UI.message("Building the coverage map of #{exec_files.size} tests ⌛️")
83
+ cli = params[:jacoco_cli] || download_jacoco_cli
84
+ paths = kotlin_and_java_paths(source_root)
85
+ source_of_class = jacoco_class_sources(cli, class_dirs).transform_values { |key| paths[key] }.compact
86
+
87
+ tests = {}
88
+ jacoco_executed_classes(cli, exec_files).each do |file, classes|
89
+ test_class, method = File.basename(file, '.ec').split('#', 2)
90
+ next UI.important("#{File.basename(file)} is not named <TestClass>#<testMethod>.ec, skipping") if method.nil?
91
+
92
+ tests["#{test_class.split('.').last}/#{method}"] = classes.flat_map { |name| source_of_class[name].to_a }.uniq
93
+ end
94
+ [source_of_class.values.flatten.uniq, tests]
95
+ end
96
+
97
+ # Flutter: one folder of lcov files per test, `<coverage_dir>/<TestFile>/<test description>/`, e.g. from
98
+ # `flutter test --coverage --coverage-path <dir>/lcov.info --name <test>`. lcov lists every library the app loaded,
99
+ # so a file counts as executed when one of its lines has hits.
100
+ def self.lcov_coverage(params, source_root)
101
+ reports = Helper::TestCoverageMapHelper.lcov_reports(params[:coverage_dir])
102
+ UI.user_error!("No per-test coverage found in '#{params[:coverage_dir]}'") if reports.empty?
103
+
104
+ UI.message("Building the coverage map of #{reports.size} tests ⌛️")
105
+ base_dir = File.expand_path(params[:lcov_base_dir] || source_root)
106
+ sources = []
107
+ tests = reports.transform_values do |files|
108
+ files = files.map { |file, executed| [relative_path(File.expand_path(file, base_dir), source_root), executed] }.reject { |file, _| file.nil? }
109
+ sources |= files.map(&:first)
110
+ files.select(&:last).map(&:first).uniq
111
+ end
112
+ [sources, tests]
113
+ end
114
+
115
+ # Class name => `<package path>/<source file name>`, from an XML report of the class files without execution data.
116
+ # Classes without code (interfaces, annotations, constants) are empty `<class/>` elements and stay out, like
117
+ # declaration-only files in llvm-cov. One report per folder, as JaCoCo fails on a class name that two folders
118
+ # define differently (e.g. a stale build).
119
+ def self.jacoco_class_sources(cli, class_dirs)
120
+ Dir.mktmpdir('jacoco') do |dir|
121
+ xml = File.join(dir, 'classes.xml')
122
+ class_dirs.each_with_object({}) do |class_dir, sources|
123
+ Helper::TestCoverageMapHelper.command('java', '-jar', cli, 'report', '--classfiles', class_dir, '--xml', xml, '--quiet')
124
+ File.read(xml).scan(/<class name="([^"]+)" sourcefilename="([^"]+)">/).each do |name, source|
125
+ sources[name] ||= "#{File.dirname(name)}/#{source}"
126
+ end
127
+ end
128
+ end
129
+ end
130
+
131
+ # Exec file => names of the classes with at least one executed probe.
132
+ def self.jacoco_executed_classes(cli, exec_files)
133
+ exec_files.each_slice(100).with_object({}) do |slice, result|
134
+ current = nil
135
+ Helper::TestCoverageMapHelper.command('java', '-jar', cli, 'execinfo', *slice).each_line do |line|
136
+ if (loaded = line[/\[INFO\] Loading exec file (.+)\.$/, 1])
137
+ current = result[loaded] = []
138
+ elsif current && (hit = line.match(/^\h+\s+(\d+) of\s+\d+\s+(\S+)$/)) && hit[1].to_i.positive?
139
+ current << hit[2]
140
+ end
141
+ end
142
+ end
143
+ end
144
+
145
+ # `<package path>/<file name>` => repository paths of the tracked Kotlin and Java files. The package comes from the
146
+ # file, as a Kotlin file does not have to sit in the folder of its package.
147
+ def self.kotlin_and_java_paths(source_root)
148
+ files = Helper::TestCoverageMapHelper.command('git', '-C', source_root, 'ls-files', '*.kt', '*.java').split("\n")
149
+ files.each_with_object({}) do |file, paths|
150
+ package = File.foreach(File.join(source_root, file)).lazy.map { |line| line[/^\s*package\s+([\w.]+)/, 1] }.find(&:itself)
151
+ (paths["#{package.to_s.tr('.', '/')}/#{File.basename(file)}".delete_prefix('/')] ||= []) << file
152
+ end
153
+ end
154
+
155
+ def self.download_jacoco_cli
156
+ version = '0.8.13'
157
+ path = File.join(Dir.tmpdir, "jacococli-#{version}.jar")
158
+ return path if File.exist?(path)
159
+
160
+ url = "https://repo1.maven.org/maven2/org/jacoco/org.jacoco.cli/#{version}/org.jacoco.cli-#{version}-nodeps.jar"
161
+ Helper::TestCoverageMapHelper.command('curl', '-sSfL', '-o', "#{path}.part", url)
162
+ File.rename("#{path}.part", path)
163
+ path
58
164
  end
59
165
 
60
166
  # Returns the instrumented files (`all`) and the files with at least one executed line (`covered`).
@@ -152,15 +258,24 @@ module Fastlane
152
258
  #####################################################
153
259
 
154
260
  def self.description
155
- 'Builds a map of which source files every test executes, from per-test LLVM coverage profiles'
261
+ 'Builds a map of which source files every test executes, from per-test LLVM, JaCoCo or lcov coverage'
156
262
  end
157
263
 
158
264
  def self.details
159
265
  <<~DETAILS
160
- Expects one folder per test, `<coverage_dir>/<TestClass>/<testMethod>/`, holding the `.profraw` files
266
+ iOS: expects one folder per test, `<coverage_dir>/<TestClass>/<testMethod>/`, holding the `.profraw` files
161
267
  the app wrote while that test ran (e.g. via `LLVM_PROFILE_FILE=<dir>/<TestClass>/<testMethod>/%m_%p%c.profraw`
162
268
  in the app's launch environment). Each folder is merged with `llvm-profdata` and exported with `llvm-cov`
163
- against the instrumented `binaries`. The resulting JSON map feeds `select_impacted_tests`.
269
+ against the instrumented `binaries`.
270
+
271
+ Android: pass `class_dirs` instead of `binaries`, and a `coverage_dir` holding one JaCoCo `.ec` file per test,
272
+ `<package>.<TestClass>#<testMethod>.ec`, which the test orchestrator writes with `-e coverage true
273
+ -e coverageFilePath <dir>/`. A source file counts as executed when one of its classes ran a probe.
274
+
275
+ Flutter: pass neither, and a `coverage_dir` with one folder of lcov `.info` files per test, `<TestFile>/<test>/`,
276
+ e.g. from `flutter test --coverage --name <test>`. A source file counts as executed when one of its lines has hits.
277
+
278
+ The resulting JSON map feeds `select_impacted_tests`.
164
279
  DETAILS
165
280
  end
166
281
 
@@ -172,13 +287,31 @@ module Fastlane
172
287
  [
173
288
  FastlaneCore::ConfigItem.new(
174
289
  key: :coverage_dir,
175
- description: 'Folder with per-test profiles, laid out as <TestClass>/<testMethod>/*.profraw',
290
+ description: 'Folder with per-test coverage: <TestClass>/<testMethod>/*.profraw (iOS), *.ec files (Android) ' \
291
+ 'or <TestFile>/<test>/*.info (Flutter)',
176
292
  verify_block: proc { |path| UI.user_error!("Coverage folder '#{path}' does not exist") unless File.directory?(path) }
177
293
  ),
178
294
  FastlaneCore::ConfigItem.new(
179
295
  key: :binaries,
180
296
  description: 'Instrumented binaries the profiles belong to, e.g. the app executable (or its .debug.dylib) and every SDK framework',
181
- type: Array
297
+ type: Array,
298
+ optional: true
299
+ ),
300
+ FastlaneCore::ConfigItem.new(
301
+ key: :class_dirs,
302
+ description: 'Android: folders with the compiled classes of the app and its modules. Switches to JaCoCo coverage',
303
+ type: Array,
304
+ optional: true
305
+ ),
306
+ FastlaneCore::ConfigItem.new(
307
+ key: :jacoco_cli,
308
+ description: 'Android: path to the JaCoCo CLI jar (org.jacoco.cli nodeps). Downloaded when not set',
309
+ optional: true
310
+ ),
311
+ FastlaneCore::ConfigItem.new(
312
+ key: :lcov_base_dir,
313
+ description: 'Flutter: folder that relative lcov SF paths start from (the package that ran the tests), source_root by default',
314
+ optional: true
182
315
  ),
183
316
  FastlaneCore::ConfigItem.new(
184
317
  key: :source_root,
@@ -240,6 +373,11 @@ module Fastlane
240
373
  coverage_dir: "test_output/coverage",
241
374
  binaries: ["App.app/App.debug.dylib", "App.app/Frameworks/SDK.framework/SDK"],
242
375
  output_path: "test_output/e2e_coverage_map.json"
376
+ )',
377
+ 'build_test_coverage_map(
378
+ coverage_dir: "coverage",
379
+ class_dirs: Dir.glob("*/build/intermediates/classes/debug/**/dirs"),
380
+ output_path: "e2e_coverage_map.json"
243
381
  )'
244
382
  ]
245
383
  end
@@ -249,7 +387,7 @@ module Fastlane
249
387
  end
250
388
 
251
389
  def self.is_supported?(platform)
252
- [:ios, :mac].include?(platform)
390
+ [:ios, :mac, :android].include?(platform)
253
391
  end
254
392
  end
255
393
  end
@@ -6,8 +6,10 @@ module Fastlane
6
6
  module Actions
7
7
  class SelectImpactedTestsAction < Action
8
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
9
+ # Kotlin's `enum class` and `fun interface`, and Dart's `interface class` and `mixin class`, name the type after the second keyword.
10
+ MODIFIERS = '(?:(?:public|open|internal|fileprivate|private|protected|final|indirect|nonisolated|data|sealed|abstract|base|annotation|value|inline|inner|enum(?=\\s+class\\b)|fun(?=\\s+interface\\b)|interface(?=\\s+class\\b)|mixin(?=\\s+class\\b)|export|default)\\s+)*'
11
+ # Dart's `extension type` declares a new type, unlike an `extension`.
12
+ DECLARATION = /^\s*(?:@\w+(?:\([^)\n]*\))?\s+)*#{MODIFIERS}(class|struct|enum|protocol|actor|typealias|typedef|mixin|extension\s+type(?:\s+const)?|extension|interface|object)\s+([A-Za-z_]\w*)/.freeze
11
13
 
12
14
  def self.run(params)
13
15
  source_root = File.expand_path(params[:source_root] || Dir.pwd)
@@ -58,6 +58,26 @@ module Fastlane
58
58
  end
59
59
  end
60
60
 
61
+ # `TestFile/test` => [[source file, executed?], ...] of the lcov reports in `<coverage_dir>/<TestFile>/<test>/`.
62
+ def self.lcov_reports(coverage_dir)
63
+ Dir.glob(File.join(coverage_dir, '*', '*', '*.info'))
64
+ .group_by { |report| File.dirname(report).split('/').last(2).join('/') }
65
+ .transform_values { |reports| reports.flat_map { |report| lcov_files(report) } }
66
+ end
67
+
68
+ # [[source file, executed?], ...] of an lcov report, in the order it lists them.
69
+ def self.lcov_files(report)
70
+ files = []
71
+ File.foreach(report) do |line|
72
+ if line.start_with?('SF:')
73
+ files << [line[3..-1].strip, false]
74
+ elsif line.start_with?('DA:') && !files.empty? && line.split(',')[1].to_i.positive?
75
+ files.last[1] = true
76
+ end
77
+ end
78
+ files
79
+ end
80
+
61
81
  def self.read_map(path)
62
82
  map = JSON.parse(File.read(path))
63
83
  UI.user_error!("Unsupported coverage map version #{map['version']}") unless map['version'] == MAP_VERSION
@@ -1,5 +1,5 @@
1
1
  module Fastlane
2
2
  module StreamActions
3
- VERSION = '0.4.14'
3
+ VERSION = '0.4.16'
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.14
4
+ version: 0.4.16
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-14 00:00:00.000000000 Z
11
+ date: 2026-10-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: xctest_list