Skip to content

Commit 62ecdf7

Browse files
committed
ios_send_app_size_metrics action + specs
1 parent 12c7494 commit 62ecdf7

File tree

6 files changed

+2390
-1
lines changed

6 files changed

+2390
-1
lines changed

Gemfile.lock

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ PATH
1212
nokogiri (~> 1.11)
1313
octokit (~> 4.18)
1414
parallel (~> 1.14)
15+
plist (~> 3.1)
1516
progress_bar (~> 1.3)
1617
rake (>= 12.3, < 14.0)
1718
rake-compiler (~> 1.0)
@@ -427,4 +428,4 @@ DEPENDENCIES
427428
yard
428429

429430
BUNDLED WITH
430-
2.2.33
431+
2.3.13

fastlane-plugin-wpmreleasetoolkit.gemspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ Gem::Specification.new do |spec|
2929
spec.add_dependency 'nokogiri', '~> 1.11' # Needed for AndroidLocalizeHelper
3030
spec.add_dependency 'octokit', '~> 4.18'
3131
spec.add_dependency 'buildkit', '~> 1.5'
32+
spec.add_dependency 'plist', '~> 3.1'
3233
spec.add_dependency 'git', '~> 1.3'
3334
spec.add_dependency 'jsonlint', '~> 0.3'
3435
spec.add_dependency 'rake', '>= 12.3', '< 14.0'
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
require 'plist'
2+
require_relative '../../helper/app_size_metrics_helper'
3+
4+
module Fastlane
5+
module Actions
6+
class IosSendAppSizeMetricsAction < Action
7+
def self.run(params)
8+
# Check input parameters
9+
base_url = URI(params[:api_base_url])
10+
api_token = params[:api_token]
11+
if (api_token.nil? || api_token.empty?) && !base_url.is_a?(URI::File)
12+
UI.user_error!('An API token is required when using an `api_base_url` with a scheme other than `file://`')
13+
end
14+
15+
# Build the payload base
16+
metrics_helper = Fastlane::WPMRT::AppSizeMetricsHelper.new(
17+
Platform: 'iOS',
18+
'App Name': params[:app_name],
19+
'App Version': params[:app_version],
20+
'Build Type': params[:build_type],
21+
Source: params[:source]
22+
)
23+
metrics_helper.add_metric(name: 'File Size', value: File.size(params[:ipa_path]))
24+
25+
# Add app-thinning metrics to the payload if a `.plist` is provided
26+
app_thinning_plist_path = params[:app_thinning_plist_path] || File.join(File.dirname(params[:ipa_path]), 'app-thinning.plist')
27+
if File.exist?(app_thinning_plist_path)
28+
plist = Plist.parse_xml(app_thinning_plist_path)
29+
plist['variants'].each do |_key, variant|
30+
variant_descriptors = variant['variantDescriptors'] || [{ 'device' => 'Universal' }]
31+
variant_descriptors.each do |desc|
32+
variant_metadata = { device: desc['device'], 'OS Version': desc['os-version'] }
33+
metrics_helper.add_metric(name: 'Download Size', value: variant['sizeCompressedApp'], meta: variant_metadata)
34+
metrics_helper.add_metric(name: 'Install Size', value: variant['sizeUncompressedApp'], meta: variant_metadata)
35+
end
36+
end
37+
end
38+
39+
# Send the payload
40+
metrics_helper.send_metrics(
41+
base_url: base_url,
42+
api_token: api_token,
43+
use_gzip: params[:use_gzip_content_encoding]
44+
)
45+
end
46+
47+
#####################################################
48+
# @!group Documentation
49+
#####################################################
50+
51+
def self.description
52+
'Send iOS app size metrics to our metrics server'
53+
end
54+
55+
def self.details
56+
<<~DETAILS
57+
Send iOS app size metrics to our metrics server.
58+
59+
In order to get Xcode generate the `app-thinning.plist` file (during `gym` and the export of the `.xcarchive`), you need to:
60+
(1) Use either `ad-hoc`, `enterprise` or `development` export method (in particular, won't work with `app-store`),
61+
(2) Provide `thinning: '<thin-for-all-variants>'` as part of your `export_options` of `gym` (or in your `options.plist` file if you use raw `xcodebuild`)
62+
See https://help.apple.com/xcode/mac/11.0/index.html#/devde46df08a
63+
64+
For builds exported with the `app-store` method, `xcodebuild` won't generate an `app-thinning.plist` file; so you will only be able to get
65+
the Universal `.ipa` file size as a metric, but won't get the per-device, broken-down install and download sizes for each thinned variant.
66+
67+
See https://github.com/Automattic/apps-metrics for the API contract expected by the Metrics server you are expected to send those metrics to.
68+
69+
Tip: If you provide a `file://` URL for the `api_base_url`, the action will write the payload on disk at the specified path instead of sending
70+
the data to a endpoint over network. This can be useful e.g. to inspect the payload and debug it, or to store the metrics data as CI artefacts.
71+
DETAILS
72+
end
73+
74+
def self.available_options
75+
[
76+
FastlaneCore::ConfigItem.new(
77+
key: :api_base_url,
78+
env_name: 'FL_IOS_SEND_APP_SIZE_METRICS_API_BASE_URL',
79+
description: 'The endpoint API URL to publish metrics to. (Note: you can also point to a `file://` URL to write the payload to a file instead)',
80+
type: String,
81+
optional: false
82+
),
83+
FastlaneCore::ConfigItem.new(
84+
key: :api_token,
85+
env_name: 'FL_IOS_SEND_APP_SIZE_METRICS_API_TOKEN',
86+
description: 'The bearer token to call the API. Required, unless `api_base_url` is a `file://` URL',
87+
type: String,
88+
optional: true
89+
),
90+
FastlaneCore::ConfigItem.new(
91+
key: :use_gzip_content_encoding,
92+
env_name: 'FL_IOS_SEND_APP_SIZE_METRICS_USE_GZIP_CONTENT_ENCODING',
93+
description: 'Specify that we should use `Content-Encoding: gzip` and gzip the body when sending the request',
94+
type: FastlaneCore::Boolean,
95+
default_value: true
96+
),
97+
FastlaneCore::ConfigItem.new(
98+
key: :app_name,
99+
env_name: 'FL_IOS_SEND_APP_SIZE_METRICS_APP_NAME',
100+
description: 'The name of the app for which we are publishing metrics, to help filter by app in the dashboard',
101+
type: String,
102+
optional: false
103+
),
104+
FastlaneCore::ConfigItem.new(
105+
key: :app_version,
106+
env_name: 'FL_IOS_SEND_APP_SIZE_METRICS_APP_VERSION',
107+
description: 'The version of the app for which we are publishing metrics, to help filter by version in the dashboard',
108+
type: String,
109+
optional: false
110+
),
111+
FastlaneCore::ConfigItem.new(
112+
key: :build_type,
113+
env_name: 'FL_IOS_SEND_APP_SIZE_METRICS_BUILD_TYPE',
114+
description: 'The build configuration for which we are publishing metrics, to help filter by build config in the dashboard. E.g. `Debug`, `Release`',
115+
type: String,
116+
optional: true
117+
),
118+
FastlaneCore::ConfigItem.new(
119+
key: :source,
120+
env_name: 'FL_IOS_SEND_APP_SIZE_METRICS_SOURCE',
121+
description: 'The type of event at the origin of that build, to help filter data in the dashboard. E.g. `pr`, `beta`, `final-release`',
122+
type: String,
123+
optional: true
124+
),
125+
FastlaneCore::ConfigItem.new(
126+
key: :ipa_path,
127+
env_name: 'FL_IOS_SEND_APP_SIZE_METRICS_IPA_PATH',
128+
description: 'The path to the .ipa to extract size information from',
129+
type: String,
130+
optional: false,
131+
default_value: Actions.lane_context[SharedValues::IPA_OUTPUT_PATH],
132+
verify_block: proc do |value|
133+
UI.user_error!('You must provide an path to an existing `.ipa` file') unless File.exist?(value)
134+
end
135+
),
136+
FastlaneCore::ConfigItem.new(
137+
key: :app_thinning_plist_path,
138+
env_name: 'FL_IOS_SEND_APP_SIZE_METRICS_APP_THINNING_PLIST_PATH',
139+
description: 'The path to the `app-thinning.plist` file to extract thinning size information from. ' \
140+
+ 'By default, will try to use the `app-thinning.plist` file next to the `ipa_path`, if that file exists',
141+
type: String,
142+
optional: true,
143+
default_value_dynamic: true
144+
),
145+
]
146+
end
147+
148+
def self.return_type
149+
:integer
150+
end
151+
152+
def self.return_value
153+
'The HTTP return code from the call. Expect a 201 when new metrics were received successfully and entries created in the database'
154+
end
155+
156+
def self.authors
157+
['automattic']
158+
end
159+
160+
def self.is_supported?(platform)
161+
[:ios, :mac].include? platform
162+
end
163+
end
164+
end
165+
end
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
require_relative './spec_helper'
2+
3+
describe Fastlane::Actions::IosSendAppSizeMetricsAction do
4+
let(:test_data_dir) { File.join(File.dirname(__FILE__), 'test-data', 'app_size_metrics') }
5+
let(:fake_ipa_size) { 1337 } # The value used in the `app-thinning.plist` and `ios-metrics-payload.json` fixtures
6+
7+
def test_app_size_action(fake_ipa_size:, expected_payload:, **other_action_args)
8+
in_tmp_dir do |tmp_dir|
9+
# Arrange
10+
output_file = File.join(tmp_dir, 'output-payload')
11+
ipa_path = File.join(tmp_dir, 'fake.ipa')
12+
File.write(ipa_path, '-fake-ipa-file-')
13+
allow(File).to receive(:size).with(ipa_path).and_return(fake_ipa_size)
14+
15+
# Act
16+
code = run_described_fastlane_action(
17+
api_base_url: File.join('file://localhost/', output_file),
18+
ipa_path: ipa_path,
19+
**other_action_args
20+
)
21+
22+
# Asserts
23+
expect(code).to eq(201)
24+
expect(File).to exist(output_file)
25+
gzip_disabled = other_action_args[:use_gzip_content_encoding] == false
26+
generated_payload = gzip_disabled ? File.read(output_file) : Zlib::GzipReader.open(output_file, &:read)
27+
# Compare the payloads as pretty-formatted JSON, to make the diff in test failures more readable if one happen
28+
expect(JSON.pretty_generate(JSON.parse(generated_payload))).to eq(JSON.pretty_generate(expected_payload)), 'Decompressed JSON payload was not as expected'
29+
# Compare the payloads as raw uncompressed data as a final check
30+
expect(generated_payload).to eq(expected_payload.to_json)
31+
end
32+
end
33+
34+
context 'when only providing an `.ipa` file with no `app-thinning.plist` file' do
35+
it 'generates the expected payload, compressed by default' do
36+
expected = {
37+
meta: [
38+
{ name: 'Platform', value: 'iOS' },
39+
{ name: 'App Name', value: 'my-app' },
40+
{ name: 'App Version', value: '1.2.3' },
41+
{ name: 'Build Type', value: 'beta' },
42+
{ name: 'Source', value: 'unit-test' },
43+
],
44+
metrics: [
45+
{ name: 'File Size', value: 123_456 },
46+
]
47+
}
48+
49+
test_app_size_action(
50+
fake_ipa_size: 123_456,
51+
expected_payload: expected,
52+
app_name: 'my-app',
53+
build_type: 'beta',
54+
app_version: '1.2.3',
55+
source: 'unit-test'
56+
)
57+
end
58+
59+
it 'generates the expected payload, uncompressed when disabling gzip' do
60+
expected = {
61+
meta: [
62+
{ name: 'Platform', value: 'iOS' },
63+
{ name: 'App Name', value: 'my-app' },
64+
{ name: 'App Version', value: '1.2.3' },
65+
{ name: 'Build Type', value: 'beta' },
66+
{ name: 'Source', value: 'unit-test' },
67+
],
68+
metrics: [
69+
{ name: 'File Size', value: 123_456 },
70+
]
71+
}
72+
73+
test_app_size_action(
74+
fake_ipa_size: 123_456,
75+
expected_payload: expected,
76+
app_name: 'my-app',
77+
build_type: 'beta',
78+
app_version: '1.2.3',
79+
source: 'unit-test',
80+
use_gzip_content_encoding: false
81+
)
82+
end
83+
end
84+
85+
context 'when using both an `.ipa` file and an existing `app-thinning.plist` file' do
86+
it 'generates the expected payload containing both the Universal and optimized thinned sizes' do
87+
app_thinning_plist_path = File.join(test_data_dir, 'app-thinning.plist')
88+
expected_fixture = File.join(test_data_dir, 'ios-metrics-payload.json')
89+
expected = JSON.parse(File.read(expected_fixture))
90+
91+
test_app_size_action(
92+
fake_ipa_size: fake_ipa_size,
93+
expected_payload: expected,
94+
app_thinning_plist_path: app_thinning_plist_path,
95+
app_name: 'wordpress',
96+
build_type: 'internal',
97+
app_version: '19.8.0.2',
98+
source: 'unit-test'
99+
)
100+
end
101+
end
102+
end

0 commit comments

Comments
 (0)