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 +4 -4
- data/lib/fastlane/plugin/stream_actions/actions/build_test_coverage_map.rb +156 -18
- data/lib/fastlane/plugin/stream_actions/actions/select_impacted_tests.rb +4 -2
- data/lib/fastlane/plugin/stream_actions/helper/test_coverage_map_helper.rb +20 -0
- 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: 026ec8f8ee8ff9ab781bd947a48ce316225ca8f933f86e1870e095cd44c1ab18
|
|
4
|
+
data.tar.gz: 83ed49f3b21c9ca683823b56c8e71b067211d01de73a5f9fffcf41b27c03f02f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
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
|
-
|
|
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`.
|
|
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
|
|
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
|
-
|
|
10
|
-
|
|
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
|
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.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-
|
|
11
|
+
date: 2026-10-21 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: xctest_list
|