emerge 0.1.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8b51ab2fab1683825f19ebb9be0eb07b7901cb49d194adcfcb3488ebf17440dd
4
+ data.tar.gz: cb8d2ead013d712c99e5213a7d4d39cac1b2ad67afa0d51aaed3b06e86a43605
5
+ SHA512:
6
+ metadata.gz: 1360c8d0acde63b549d69c23468fdd24b7c4a37e399988faec47e88060d9edf7e9abf945657f6deaa4af052c9b9cc307b6f4cd2c5eefe741bc3e9cce4dbff1b0
7
+ data.tar.gz: 7c515b04f02ef2c28e9452f9edeb36d62bf4754fe52c0a78b727b202c233a1291d418cab3d341e2eab527afd7fd5ffc77726b2e7af85c46693538fc273cfd3c6
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2024-XX-XX
4
+
5
+ - Initial release
data/README.md ADDED
@@ -0,0 +1,94 @@
1
+ # Emerge CLI
2
+
3
+ The official CLI for Emerge Tools.
4
+
5
+ [Emerge](https://emergetools.com) offers a suite of products to help optimize app size, performance, and quality by detecting regressions before they make it to production. This plugin provides a set of actions to interact with the Emerge API.
6
+
7
+ ## Installation
8
+
9
+ This tool is packaged as a Ruby Gem which can be installed by:
10
+
11
+ ```
12
+ gem install emerge
13
+ ```
14
+
15
+ ## API Key
16
+
17
+ Follow our guide to obtain an [API key](https://docs.emergetools.com/docs/uploading-basics#obtain-an-api-key) for your organization. The API Token is used by the CLI to authenticate with the Emerge API. The CLI will automatically pick up the API key if configured as an `EMERGE_API_TOKEN` environment variable, or you can manually pass it into individual commands.
18
+
19
+ ## Snapshots
20
+
21
+ Uploads a directory of images to be used in [Emerge Snapshot Testing](https://docs.emergetools.com/docs/snapshot-testing).
22
+
23
+ Run `emerge upload snapshots -h` for a full list of supported options.
24
+
25
+ Example:
26
+
27
+ ```shell
28
+ emerge upload snapshots \
29
+ --name "AwesomeApp" \
30
+ --id "com.emerge.awesomeapp" \
31
+ --repo-name "EmergeTools/AwesomeApp" \
32
+ path/to/snapshot/images
33
+ ```
34
+
35
+ ### Git Configuration
36
+
37
+ For CI diffs to work, Emerge needs the appropriate Git `sha` and `base_sha` values set on each build. Emerge will automatically compare a build at `sha` against the build we find matching the `base_sha` for a given application id. We also recommend setting `pr_number`, `branch`, and `repo_name` for the best experience.
38
+
39
+ For example:
40
+
41
+ - `sha`: `pr-branch-commit-1`
42
+ - `base_sha`: `main-branch-commit-1`
43
+ - `pr_number`: `42`
44
+ - `branch`: `my-awesome-feature`
45
+ - `repo_name`: `EmergeTools/hackernews`
46
+
47
+ Will compare the snapshot diffs of your pull request changes.
48
+
49
+ This plugin will automatically configure Git values for you assuming certain Github workflow triggers:
50
+
51
+ ```yaml
52
+ on:
53
+ # Produce base builds with a 'sha' when commits are pushed to the main branch
54
+ push:
55
+ branches: [main]
56
+
57
+ # Produce branch comparison builds with `sha` and `base_sha` when commits are pushed
58
+ # to open pull requests
59
+ pull_request:
60
+ branches: [main]
61
+ ...
62
+ ```
63
+
64
+ If this doesn't cover your use-case, manually set the `sha` and `base_sha` values when calling the Emerge plugin.
65
+
66
+ ### Using with swift-snapshot-testing
67
+
68
+ Snapshots generated via [swift-snapshot-testing](https://github.com/pointfreeco/swift-snapshot-testing) are natively supported by the CLI by setting `--client-library swift-snapshot-testing` and a `--project-root` directory. This will scan your project for all images found in `__Snapshot__` directories.
69
+
70
+ Example:
71
+
72
+ ```shell
73
+ emerge upload snapshots \
74
+ --name "AwesomeApp swift-snapshot-testing" \
75
+ --id "com.emerge.awesomeapp.swift-snapshot-testing" \
76
+ --repo-name "EmergeTools/AwesomeApp" \
77
+ --client-library swift-snapshot-testing \
78
+ --project-root /my/awesomeapp/ios/repo
79
+ ```
80
+
81
+ ### Using with Paparazzi
82
+
83
+ Snapshots generated via [Paparazzi](https://github.com/cashapp/paparazzi) are natively supported by the CLI by setting `--client-library paparazzi` and a `--project-root` directory. This will scan your project for all images found in `src/test/snapshots/images` directories.
84
+
85
+ Example:
86
+
87
+ ```shell
88
+ emerge upload snapshots \
89
+ --name "AwesomeApp Paparazzi" \
90
+ --id "com.emerge.awesomeapp.paparazzi" \
91
+ --repo-name "EmergeTools/AwesomeApp" \
92
+ --client-library paparazzi \
93
+ --project-root /my/awesomeapp/android/repo
94
+ ```
data/exe/emerge ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../lib/emerge_cli'
5
+
6
+ Dry::CLI.new(EmergeCLI).call
@@ -0,0 +1,235 @@
1
+ require 'dry/cli'
2
+ require 'json'
3
+ require 'uri'
4
+ require 'chunky_png'
5
+ require 'async'
6
+ require 'async/barrier'
7
+ require 'async/semaphore'
8
+ require 'async/http/internet/instance'
9
+ require 'yaml'
10
+
11
+ require 'tty-prompt'
12
+ require 'tty-table'
13
+
14
+ module EmergeCLI
15
+ module Commands
16
+ module Config
17
+ class SnapshotsIOS < EmergeCLI::Commands::GlobalOptions
18
+ desc 'Configure snapshot testing for iOS'
19
+
20
+ # Optional options
21
+ option :interactive, type: :boolean, required: false,
22
+ desc: 'Run interactively'
23
+ option :clear, type: :boolean, required: false, desc: 'Clear existing configuration'
24
+ option :os_version, type: :string, required: true, desc: 'OS version'
25
+ option :launch_arguments, type: :array, required: false, desc: 'Launch arguments to set'
26
+ option :env_variables, type: :array, required: false, desc: 'Environment variables to set'
27
+ option :exact_match_excluded_previews, type: :array, required: false, desc: 'Exact match excluded previews'
28
+ option :regex_excluded_previews, type: :array, required: false, desc: 'Regex excluded previews'
29
+
30
+ # Constants
31
+ EXCLUDED_PREVIEW_PROMPT = 'Do you want to exclude any previews by exact match?'.freeze
32
+ EXCLUDED_PREVIEW_FINISH_PROMPT = 'Enter the previews you want to exclude (leave blank to finish)'.freeze
33
+ EXCLUDED_REGEX_PREVIEW_PROMPT = 'Do you want to exclude any previews by regex?'.freeze
34
+ EXCLUDED_REGEX_PREVIEW_FINISH_PROMPT = 'Enter the previews you want to exclude (leave blank to finish)'.freeze
35
+ ARGUMENTS_PROMPT = 'Do you want to set any arguments?'.freeze
36
+ ARGUMENTS_FINISH_PROMPT = 'Enter the argument you want to set (leave blank to finish)'.freeze
37
+ ENV_VARIABLES_PROMPT = 'Do you want to set any environment variables?'.freeze
38
+ ENV_VARIABLES_FINISH_PROMPT = "Enter the environment variable you want to set (leave blank to finish) with \
39
+ format KEY=VALUE".freeze
40
+ AVAILABLE_OS_VERSIONS = ['17.2', '17.5', '18.0'].freeze
41
+
42
+ def initialize; end
43
+
44
+ def call(**options)
45
+ @options = options
46
+ before(options)
47
+
48
+ Sync do
49
+ validate_options
50
+
51
+ run_interactive_mode if @options[:interactive]
52
+
53
+ run_non_interactive_mode if !@options[:interactive]
54
+
55
+ Logger.warn 'Remember to copy `emerge_config.yml` to your project XCArchive before uploading it!'
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def validate_options
62
+ if @options[:interactive] && (!@options[:os_version].nil? || !@options[:launch_arguments].nil? ||
63
+ !@options[:env_variables].nil? || !@options[:exact_match_excluded_previews].nil? ||
64
+ !@options[:regex_excluded_previews].nil?)
65
+ Logger.warn 'All options are ignored when using interactive mode'
66
+ end
67
+ end
68
+
69
+ def run_interactive_mode
70
+ prompt = TTY::Prompt.new
71
+
72
+ override_config = false
73
+ if File.exist?('emerge_config.yml')
74
+ Logger.warn 'There is already a emerge_config.yml file.'
75
+ prompt.yes?('Do you want to overwrite it?', default: false) do |answer|
76
+ override_config = true if answer
77
+ end
78
+ end
79
+
80
+ if !override_config && File.exist?('emerge_config.yml')
81
+ config = YAML.load_file('emerge_config.yml')
82
+ config['snapshots']['ios']['runSettings'] = []
83
+ else
84
+ config = {
85
+ 'version' => 2.0,
86
+ 'snapshots' => {
87
+ 'ios' => {
88
+ 'runSettings' => []
89
+ }
90
+ }
91
+ }
92
+ end
93
+
94
+ Logger.info 'Creating a new config file'
95
+
96
+ end_config = false
97
+ loop do
98
+ os_version = get_os_version(prompt)
99
+
100
+ excluded_previews = get_array_from_user(prompt, EXCLUDED_PREVIEW_PROMPT, EXCLUDED_PREVIEW_FINISH_PROMPT)
101
+ excluded_regex_previews = get_array_from_user(prompt, EXCLUDED_REGEX_PREVIEW_PROMPT,
102
+ EXCLUDED_REGEX_PREVIEW_FINISH_PROMPT)
103
+ arguments_array = get_array_from_user(prompt, ARGUMENTS_PROMPT, ARGUMENTS_FINISH_PROMPT)
104
+ env_variables_array = get_array_from_user(prompt, ENV_VARIABLES_PROMPT, ENV_VARIABLES_FINISH_PROMPT)
105
+
106
+ excluded = get_parsed_previews(excluded_previews, excluded_regex_previews)
107
+ env_variables = get_parsed_env_variables(env_variables_array)
108
+
109
+ os_settings = {
110
+ 'osVersion' => os_version,
111
+ 'excludedPreviews' => excluded,
112
+ 'envVariables' => env_variables,
113
+ 'arguments' => arguments_array
114
+ }
115
+ show_config(os_settings)
116
+ save = prompt.yes?('Do you want to save this setting?')
117
+ config['snapshots']['ios']['runSettings'].push(os_settings) if save
118
+
119
+ end_config = !prompt.yes?('Do you want to continue adding more settings?')
120
+ break if end_config
121
+ end
122
+
123
+ File.write('emerge_config.yml', config.to_yaml)
124
+ Logger.info 'Configuration file created successfully!'
125
+ end
126
+
127
+ def run_non_interactive_mode
128
+ config = {}
129
+ if File.exist?('emerge_config.yml')
130
+ config = YAML.load_file('emerge_config.yml')
131
+ if !@options[:clear] && !config['snapshots'].nil? && !config['snapshots']['ios'].nil? &&
132
+ !config['snapshots']['ios']['runSettings'].nil?
133
+ raise 'There is already a configuration file with settings. Use the --clear flag to overwrite it.'
134
+ end
135
+
136
+ config['snapshots']['ios']['runSettings'] = []
137
+
138
+ else
139
+ config = {
140
+ 'version' => 2.0,
141
+ 'snapshots' => {
142
+ 'ios' => {
143
+ 'runSettings' => []
144
+ }
145
+ }
146
+ }
147
+ end
148
+
149
+ excluded_previews = get_parsed_previews(@options[:exact_match_excluded_previews] || [],
150
+ @options[:regex_excluded_previews] || [])
151
+ env_variables = get_parsed_env_variables(@options[:env_variables] || [])
152
+
153
+ os_version = @options[:os_version]
154
+ if os_version.nil?
155
+ Logger.warn 'No OS version was provided, defaulting to 17.5'
156
+ os_version = '17.5'
157
+ end
158
+
159
+ os_settings = {
160
+ 'osVersion' => os_version,
161
+ 'excludedPreviews' => excluded_previews,
162
+ 'envVariables' => env_variables,
163
+ 'arguments' => @options[:launch_arguments] || []
164
+ }
165
+ config['snapshots']['ios']['runSettings'].push(os_settings)
166
+ File.write('emerge_config.yml', config.to_yaml)
167
+ Logger.info 'Configuration file created successfully!'
168
+ show_config(os_settings)
169
+ end
170
+
171
+ def get_os_version(prompt)
172
+ os_version = prompt.select('Select the OS version you want to run the tests on') do |answer|
173
+ AVAILABLE_OS_VERSIONS.each do |version|
174
+ answer.choice version, version.to_f
175
+ end
176
+ answer.choice 'Custom', 'custom'
177
+ end
178
+ os_version = prompt.ask('Enter the OS version you want to run the tests on') if os_version == 'custom'
179
+ os_version
180
+ end
181
+
182
+ def get_array_from_user(prompt, first_prompt_message, second_prompt_message)
183
+ continue = prompt.yes?(first_prompt_message)
184
+ return [] if !continue
185
+ array = []
186
+ loop do
187
+ item = prompt.ask(second_prompt_message)
188
+ if item == '' || item.nil?
189
+ continue = false
190
+ else
191
+ array.push(item)
192
+ end
193
+ break unless continue
194
+ end
195
+ array
196
+ end
197
+
198
+ def show_config(config)
199
+ table = TTY::Table.new(
200
+ header: %w[Key Value],
201
+ rows: config.to_a
202
+ )
203
+ puts table.render(:ascii)
204
+ end
205
+
206
+ def get_parsed_previews(previews_exact, previews_regex)
207
+ excluded = []
208
+ previews_exact.each do |preview|
209
+ excluded.push({
210
+ 'type' => 'exact',
211
+ 'value' => preview
212
+ })
213
+ end
214
+ previews_regex.each do |preview|
215
+ excluded.push({
216
+ 'type' => 'regex',
217
+ 'value' => preview
218
+ })
219
+ end
220
+ excluded
221
+ end
222
+
223
+ def get_parsed_env_variables(env_variables)
224
+ env_variables_array_fixed = []
225
+ env_variables.each do |env_variable|
226
+ key, value = env_variable.split('=')
227
+ env_variables_array_fixed.push({
228
+ 'key' => key, 'value' => value
229
+ })
230
+ end
231
+ end
232
+ end
233
+ end
234
+ end
235
+ end
@@ -0,0 +1,15 @@
1
+ require 'dry/cli'
2
+ require 'logger'
3
+
4
+ module EmergeCLI
5
+ module Commands
6
+ class GlobalOptions < Dry::CLI::Command
7
+ option :debug, type: :boolean, default: false, desc: 'Enable debug logging'
8
+
9
+ def before(args)
10
+ log_level = args[:debug] ? ::Logger::DEBUG : ::Logger::INFO
11
+ EmergeCLI::Logger.configure(log_level)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,124 @@
1
+ require 'dry/cli'
2
+ require 'fileutils'
3
+
4
+ module EmergeCLI
5
+ module Commands
6
+ module Integrate
7
+ class Fastlane < EmergeCLI::Commands::GlobalOptions
8
+ desc 'Integrate Emerge into your iOS project via Fastlane'
9
+
10
+ argument :path, type: :string, required: false, default: '.',
11
+ desc: 'Project path (defaults to current directory)'
12
+
13
+ def call(path: '.', **_options)
14
+ @project_path = File.expand_path(path)
15
+ Logger.info "Project path: #{@project_path}"
16
+
17
+ Logger.info '🔍 Detecting project type...'
18
+ detector = ProjectDetector.new(@project_path)
19
+
20
+ if detector.ios_project?
21
+ Logger.info '📱 iOS project detected!'
22
+ setup_ios
23
+ else
24
+ Logger.error "❌ Error: Could not detect project. Make sure you're in the root directory of an iOS project."
25
+ exit 1
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def setup_ios
32
+ Logger.info 'Setting up Emerge Tools for iOS project using Fastlane...'
33
+
34
+ setup_gemfile
35
+ setup_fastfile
36
+
37
+ # Install Emerge Fastlane plugin
38
+ Logger.info 'Installing Emerge Fastlane plugin...'
39
+ system('fastlane add_plugin emerge')
40
+
41
+ print_ios_completion_message
42
+ end
43
+
44
+ def setup_gemfile
45
+ gemfile_path = File.join(@project_path, 'Gemfile')
46
+ if File.exist?(gemfile_path)
47
+ Logger.info 'Updating existing Gemfile...'
48
+ current_content = File.read(gemfile_path)
49
+ current_content << "\ngem 'fastlane'" unless current_content.include?('gem "fastlane"')
50
+ current_content << "\ngem 'xcpretty'" unless current_content.include?('gem "xcpretty"')
51
+ File.write(gemfile_path, current_content)
52
+ else
53
+ Logger.error 'No Gemfile found. Please follow the Fastlane setup instructions before running this.'
54
+ exit 1
55
+ end
56
+
57
+ Logger.info 'Installing gems...'
58
+ system('bundle install')
59
+ end
60
+
61
+ def setup_fastfile
62
+ fastfile_dir = File.join(@project_path, 'fastlane')
63
+ FileUtils.mkdir_p(fastfile_dir)
64
+ fastfile_path = File.join(fastfile_dir, 'Fastfile')
65
+
66
+ if File.exist?(fastfile_path)
67
+ Logger.info 'Updating existing Fastfile...'
68
+ update_existing_fastfile(fastfile_path)
69
+ else
70
+ Logger.error 'No Fastfile found. Please follow the Fastlane setup instructions before running this.'
71
+ exit 1
72
+ end
73
+ end
74
+
75
+ def update_existing_fastfile(fastfile_path)
76
+ current_content = File.read(fastfile_path)
77
+
78
+ # Add platform :ios block if not present
79
+ current_content += "\nplatform :ios do\nend\n" unless current_content.match?(/platform\s+:ios\s+do/)
80
+
81
+ # Add app_size lane if not present
82
+ unless current_content.match?(/^\s*lane\s*:app_size\s*do/)
83
+ app_size_lane = <<~'RUBY'.gsub(/^/, ' ')
84
+ lane :app_size do
85
+ # NOTE: If you already have a lane setup to build your app, then you can that instead of this and call emerge() after it.
86
+ build_app(scheme: ENV["SCHEME_NAME"], export_method: "development")
87
+ emerge(tag: ENV['EMERGE_BUILD_TYPE'] || "default")
88
+ end
89
+ RUBY
90
+ current_content.sub!(/platform\s+:ios\s+do.*$/) { |match| "#{match}\n#{app_size_lane}" }
91
+ end
92
+
93
+ # Add snapshots lane if not present
94
+ unless current_content.match?(/^\s*lane\s*:build_upload_emerge_snapshot\s*do/)
95
+ snapshot_lane = <<~'RUBY'.gsub(/^/, ' ')
96
+ desc 'Build and upload snapshot build to Emerge Tools'
97
+ lane :build_upload_emerge_snapshot do
98
+ emerge_snapshot(scheme: ENV["SCHEME_NAME"])
99
+ end
100
+ RUBY
101
+ current_content.sub!(/lane\s+:app_size\s+do.*?end/m) { |match| "#{match}\n\n#{snapshot_lane}" }
102
+ end
103
+
104
+ # Clean up any multiple blank lines
105
+ current_content.gsub!(/\n{3,}/, "\n\n")
106
+
107
+ File.write(fastfile_path, current_content)
108
+ end
109
+
110
+ def command_exists?(command)
111
+ system("which #{command} > /dev/null 2>&1")
112
+ end
113
+
114
+ def print_ios_completion_message
115
+ Logger.info "✅ iOS setup complete! Don't forget to:"
116
+ Logger.info '1. Set your EMERGE_API_TOKEN environment variable (both locally and in your CI/CD pipeline)'
117
+ Logger.info '2. Set your SCHEME_NAME environment variable'
118
+ Logger.info "3. Run 'fastlane app_size' to analyze your app"
119
+ Logger.info "4. Run 'fastlane build_upload_emerge_snapshot' to analyze your snapshots"
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,26 @@
1
+ module EmergeCLI
2
+ module Commands
3
+ module Upload
4
+ module ClientLibraries
5
+ class Default
6
+ def initialize(image_paths)
7
+ @image_paths = image_paths
8
+ end
9
+
10
+ def image_files
11
+ @image_paths.flat_map { |path| Dir.glob("#{path}/**/*.png") }
12
+ end
13
+
14
+ def parse_file_info(image_path)
15
+ file_name = File.basename(image_path)
16
+ {
17
+ file_name:,
18
+ group_name: File.basename(image_path, '.*'),
19
+ variant_name: nil
20
+ }
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,29 @@
1
+ module EmergeCLI
2
+ module Commands
3
+ module Upload
4
+ module ClientLibraries
5
+ class Paparazzi
6
+ def initialize(project_root)
7
+ @project_root = project_root
8
+ end
9
+
10
+ def image_files
11
+ # TODO: support "paparazzi.snapshot.dir" dynamic config
12
+ Dir.glob(File.join(@project_root, '**/src/test/snapshots/images/**/*.png'))
13
+ end
14
+
15
+ def parse_file_info(image_path)
16
+ file_name = image_path.split('src/test/snapshots/images/').last
17
+ test_class_name = File.basename(File.dirname(image_path))
18
+
19
+ {
20
+ file_name:,
21
+ group_name: test_class_name, # TODO: add support for nicer group names
22
+ variant_name: File.basename(file_name, '.*')
23
+ }
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,28 @@
1
+ module EmergeCLI
2
+ module Commands
3
+ module Upload
4
+ module ClientLibraries
5
+ class SwiftSnapshotTesting
6
+ def initialize(project_root)
7
+ @project_root = project_root
8
+ end
9
+
10
+ def image_files
11
+ Dir.glob(File.join(@project_root, '**/__Snapshots__/**/*.png'))
12
+ end
13
+
14
+ def parse_file_info(image_path)
15
+ file_name = image_path.split('__Snapshots__/').last
16
+ test_class_name = File.basename(File.dirname(image_path))
17
+
18
+ {
19
+ file_name:,
20
+ group_name: test_class_name.sub(/Tests$/, ''),
21
+ variant_name: File.basename(file_name, '.*')
22
+ }
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end