percy-appium-app 1.0.0 → 1.0.1

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: bdb1ff6892c099d297d97844d8dd24985e6be0361e78da39e274d844945ea869
4
- data.tar.gz: ba73fd618e623111852a73900165ae0a9315fe5a8ebb768ca4d3eb8d86928db5
3
+ metadata.gz: 85a82be9fb7970cea7e6bfbf92341377a6cf4647f07ce09b3a3c4bb52ff25c11
4
+ data.tar.gz: 03be307ae51ff85c7853cf0284ded40713a91f8bbf4ce36f8f6b7ae69165d26e
5
5
  SHA512:
6
- metadata.gz: e94a5a62b0a3c82630b63ba011a423978c924bfd31c2ad4de7ea92c1f1a5ce532530a5fcbd2de72f8c89908c404ef353b1ac3c415abb3d3806e6f5eaac125875
7
- data.tar.gz: 68fbb5b42c1df5e5b8d39f75debe939d7e76408ff8370e7998f04f60adec7fe37a875504e13b93b9876a39595b67195299d4f4a348407e4064770a5a20e6610d
6
+ metadata.gz: 986183165da611f44bfd356562c084bdb942d7f87a2e38418746562c1d056894e8a373c4fe3e61a5aa2c3a44a48c9c42d7701e845b30eec7727fdafbe2a78faa
7
+ data.tar.gz: a21a495cb3cda29740602eb262e23ef67ed7c3fce651d191d1ec91c73bc56b0540f6c8cf55772816da6acdcadf1d07c6040e35a49f4fdfae95e1ca0016ccf2fe
@@ -27,8 +27,7 @@ jobs:
27
27
 
28
28
  container:
29
29
  # A Docker image with Semgrep installed. Do not change this.
30
- image: returntocorp/semgrep
31
-
30
+ image: returntocorp/semgrep:1.166.0
32
31
  # Skip any PR created by dependabot to avoid permission issues:
33
32
  if: (github.actor != 'dependabot[bot]')
34
33
 
@@ -11,7 +11,7 @@ jobs:
11
11
  with:
12
12
  ruby-version: 2.6
13
13
  bundler-cache: true
14
- - uses: actions/cache@v2
14
+ - uses: actions/cache@v3
15
15
  with:
16
16
  path: "./vendor/bundle"
17
17
  key: v1/${{ runner.os }}/ruby-2.6/${{ hashFiles('**/Gemfile.lock') }}
@@ -2,6 +2,7 @@ name: Test
2
2
  on:
3
3
  push:
4
4
  branches: [main]
5
+ pull_request:
5
6
  workflow_dispatch:
6
7
  inputs:
7
8
  branch:
@@ -12,6 +13,10 @@ jobs:
12
13
  test:
13
14
  name: Test
14
15
  strategy:
16
+ # Run every Ruby version to completion so one leg failing doesn't cancel
17
+ # the others. Ruby 3.0's appium_lib API drift is fixed by pinning
18
+ # appium_lib ~> 12.0 (see Gemfile), so all three legs are green.
19
+ fail-fast: false
15
20
  matrix:
16
21
  os: [ubuntu-latest]
17
22
  ruby: ['2.6', '2.7', '3.0']
@@ -31,10 +36,13 @@ jobs:
31
36
  with:
32
37
  ruby-version: ${{matrix.ruby}}
33
38
  bundler-cache: true
34
- - uses: actions/cache@v2
39
+ - uses: actions/cache@v3
35
40
  with:
36
41
  path: "./vendor/bundle"
37
42
  key: v1/${{ runner.os }}/ruby-${{ matrix.ruby }}/${{ hashFiles('**/Gemfile.lock') }}
38
43
  restore-keys: v1/${{ runner.os }}/ruby-${{ matrix.ruby }}/
39
44
  - run: bundle install
40
- - run: for file in specs/*.rb; do echo $file; bundle exec ruby $file; done
45
+ # Run each spec via the SimpleCov runner so per-process coverage is
46
+ # recorded and merged, then collate the merged result and enforce the gate.
47
+ - run: for file in specs/*.rb; do echo "$file"; bundle exec ruby specs/support/run_spec.rb "$file"; done
48
+ - run: bundle exec ruby specs/support/coverage_check.rb
data/.gitignore CHANGED
@@ -1 +1,4 @@
1
1
  Gemfile.lock
2
+ /vendor/
3
+ /.bundle/
4
+ coverage/
data/Gemfile CHANGED
@@ -2,8 +2,12 @@
2
2
 
3
3
  source 'https://rubygems.org'
4
4
 
5
- gem 'appium_console'
6
- gem 'appium_lib', '>= 11.2.0'
5
+ # Pin appium_lib to the 12.x line so all Ruby legs (2.6/2.7/3.0) resolve
6
+ # appium_lib_core ~> 5.0, which still exposes Appium::Core::Base::SearchContext.
7
+ # Unpinned, Ruby 3.0 resolved appium_lib 15 / appium_lib_core 9.x, which removed
8
+ # that constant and broke the specs. appium_console 3.0.0 requires appium_lib = 12.0.0.
9
+ gem 'appium_console', '~> 3.0'
10
+ gem 'appium_lib', '~> 12.0'
7
11
  gem 'dotenv'
8
12
  gem 'minitest'
9
13
  gem 'rubocop', require: false
@@ -11,3 +15,4 @@ gem 'tempfile'
11
15
  gem 'webmock', '~> 3.18', '>= 3.18.1'
12
16
  gem 'webrick', '~> 1.3', '>= 1.3.1'
13
17
  gem 'rake', '~> 13.0'
18
+ gem 'simplecov', require: false
@@ -9,14 +9,26 @@ module Percy
9
9
  def initialize(driver)
10
10
  super(driver)
11
11
  @_bars = nil
12
+ # Intentionally left as the original lookup: this path already degrades to
13
+ # driver.get_system_bars consistently across all appium_lib_core versions
14
+ # (the rect read yields a non-Hash, so the rect arithmetic in
15
+ # get_system_bars rescues to nil and falls back), so it is out of scope for
16
+ # the snake_case capability fix.
12
17
  @_viewport_rect = capabilities.to_json['viewportRect']
13
18
  end
14
19
 
15
20
  def device_screen_size
16
- caps = capabilities
17
- caps = caps.as_json unless caps.is_a?(Hash)
18
- width, height = caps['deviceScreenSize'].split('x')
19
- { 'width' => width.to_i, 'height' => height.to_i }
21
+ # Use string keys to match the IosMetadata implementation and every
22
+ # consumer (generic_provider, app_automate, _get_tag), all of which read
23
+ # device_screen_size['width'] / ['height'].
24
+ device_screen_size_cap = get_capability_value('deviceScreenSize')
25
+ if device_screen_size_cap.nil?
26
+ size = driver.window_size
27
+ { 'width' => size.width.to_i, 'height' => size.height.to_i }
28
+ else
29
+ width, height = device_screen_size_cap.split('x')
30
+ { 'width' => width.to_i, 'height' => height.to_i }
31
+ end
20
32
  end
21
33
 
22
34
  def get_system_bars
@@ -68,11 +80,13 @@ module Percy
68
80
 
69
81
  def _device_name
70
82
  if @device_name.nil?
71
- desired_caps = capabilities.to_json['desired'] || {}
72
- device_name = desired_caps['deviceName']
83
+ # Normalize the nested desired-caps hash too, so its keys are matched
84
+ # regardless of casing (camelCase or appium_lib_core 13+ snake_case).
85
+ desired_caps = Percy::Metadata.normalize_hash(get_capability_value('desired'))
86
+ device_name = desired_caps['devicename']
73
87
  device = desired_caps['device']
74
88
  device_name ||= device
75
- device_model = capabilities.to_json['deviceModel']
89
+ device_model = get_capability_value('deviceModel')
76
90
  @device_name = device_name || device_model
77
91
  end
78
92
  @device_name
@@ -67,11 +67,7 @@ module Percy
67
67
  end
68
68
 
69
69
  def device_name
70
- if @device_name.nil?
71
- caps = capabilities
72
- caps = caps.as_json unless caps.is_a?(Hash)
73
- @device_name = caps['deviceName']
74
- end
70
+ @device_name = get_capability_value('deviceName') if @device_name.nil?
75
71
  @device_name
76
72
  end
77
73
 
@@ -25,19 +25,54 @@ module Percy
25
25
  caps
26
26
  end
27
27
 
28
+ # Normalizes a capability key so lookups are resilient to the differences
29
+ # across appium_lib_core versions and protocols: camelCase ("platformName"),
30
+ # snake_case ("platform_name", as returned by appium_lib_core 13+),
31
+ # SCREAMING ("PLATFORM_NAME") and the W3C vendor prefix ("appium:platformName").
32
+ # Note: all colons are stripped, not just the vendor-prefix one. This is safe
33
+ # for every known Appium capability (e.g. "bstack:options" -> "bstackoptions").
34
+ def self.normalize_capability_key(key)
35
+ key.to_s.downcase.gsub(/[_:]/, '').sub(/\Aappium/, '')
36
+ end
37
+
38
+ # Builds a {normalized_key => value} view of a capabilities hash. First key
39
+ # wins, so a camelCase key (e.g. "platformName") takes precedence over a
40
+ # snake_case duplicate ("platform_name") when both are present, matching the
41
+ # previous MetadataResolver semantics.
42
+ def self.normalize_hash(hash)
43
+ normalized = {}
44
+ (hash || {}).each do |k, v|
45
+ nk = normalize_capability_key(k)
46
+ normalized[nk] = v unless normalized.key?(nk)
47
+ end
48
+ normalized
49
+ end
50
+
51
+ # Builds a {normalized_key => value} view of the driver's capabilities,
52
+ # coercing the appium_lib_core Capabilities object into a plain Hash first.
53
+ def self.normalized_capabilities(driver)
54
+ caps = driver.capabilities
55
+ caps = caps.as_json if caps.respond_to?(:as_json) && !caps.is_a?(Hash)
56
+ caps = caps.to_h if caps.respond_to?(:to_h) && !caps.is_a?(Hash)
57
+ normalize_hash(caps)
58
+ end
59
+
60
+ # Reads capabilities fresh on every call (no memoization) so callers always
61
+ # observe the driver's current capabilities, matching the prior behaviour.
62
+ def get_capability_value(name)
63
+ self.class.normalized_capabilities(driver)[self.class.normalize_capability_key(name)]
64
+ end
65
+
28
66
  def session_id
29
67
  driver.session_id
30
68
  end
31
69
 
32
70
  def os_name
33
- capabilities['platformName']
71
+ get_capability_value('platformName')
34
72
  end
35
73
 
36
74
  def os_version
37
- caps = capabilities
38
- caps = caps.as_json unless caps.is_a?(Hash)
39
-
40
- os_version = caps['os_version'] || caps['platformVersion'] || ''
75
+ os_version = get_capability_value('os_version') || get_capability_value('platformVersion') || ''
41
76
  os_version = @os_version || os_version
42
77
  begin
43
78
  os_version.to_f.to_i.to_s
@@ -51,7 +86,7 @@ module Percy
51
86
  end
52
87
 
53
88
  def get_orientation(**kwargs)
54
- orientation = kwargs[:orientation] || capabilities['orientation'] || 'PORTRAIT'
89
+ orientation = kwargs[:orientation] || get_capability_value('orientation') || 'PORTRAIT'
55
90
  orientation = orientation.downcase
56
91
  orientation = orientation == 'auto' ? _orientation : orientation
57
92
  orientation.upcase
@@ -1,15 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative '../exceptions/exceptions'
4
+ require_relative 'metadata'
4
5
  require_relative 'android_metadata'
5
6
  require_relative 'ios_metadata'
6
7
 
7
8
  module Percy
8
9
  class MetadataResolver
9
10
  def self.resolve(driver)
10
- capabilities = driver.capabilities
11
- capabilities = capabilities.as_json unless capabilities.is_a?(Hash)
12
- platform_name = capabilities.fetch('platformName', '').downcase
11
+ # Resolve via normalized capability keys so platformName is found
12
+ # regardless of casing/prefix (camelCase, snake_case as in appium_lib_core
13
+ # 13+, or the appium: vendor prefix).
14
+ platform_name = Percy::Metadata.normalized_capabilities(driver)['platformname']&.to_s&.downcase
13
15
  case platform_name
14
16
  when 'android'
15
17
  Percy::AndroidMetadata.new(driver)
@@ -137,7 +137,10 @@ module Percy
137
137
 
138
138
  def get_regions_by_xpath(elements_array, xpaths)
139
139
  xpaths.each do |xpath|
140
- element = driver.find_element(Appium::Core::Base::SearchContext::FINDERS[:xpath], xpath)
140
+ # Pass the :xpath finder symbol directly. appium_lib_core 12.x removed
141
+ # Appium::Core::Base::SearchContext::FINDERS; the symbol form works
142
+ # across all supported appium_lib versions.
143
+ element = driver.find_element(:xpath, xpath)
141
144
  selector = "xpath: #{xpath}"
142
145
  if element
143
146
  region = get_region_object(selector, element)
data/percy/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Percy
4
- VERSION = '1.0.0'.freeze
4
+ VERSION = '1.0.1'.freeze
5
5
  end
@@ -29,7 +29,35 @@ class TestAndroidMetadata < Minitest::Test
29
29
  @mock_webdriver.expect(:capabilities, android_capabilities.merge('viewportRect' => viewport))
30
30
 
31
31
  assert(viewport, @android_metadata.viewport)
32
+ @mock_webdriver.verify
33
+ end
34
+
35
+ def test_device_screen_size_when_device_screen_size_is_nil
36
+ # Mock capabilities to return a hash without 'deviceScreenSize'
37
+ android_capabilities = get_android_capabilities
38
+ android_capabilities.delete('deviceScreenSize')
39
+ @mock_webdriver.expect(:capabilities, android_capabilities)
40
+
41
+ # Mock driver.window_size to return a double with width and height
42
+ mock_window_size = Minitest::Mock.new
43
+ mock_window_size.expect(:width, 1080)
44
+ mock_window_size.expect(:height, 1920)
45
+ @mock_webdriver.expect(:window_size, mock_window_size)
32
46
 
47
+ # Call the method and assert the result
48
+ result = @android_metadata.device_screen_size
49
+ assert_equal({ 'width' => 1080, 'height' => 1920 }, result)
50
+
51
+ # Verify mocks
52
+ mock_window_size.verify
53
+ @mock_webdriver.verify
54
+ end
55
+
56
+ def test_device_screen_size_when_device_screen_size_is_present
57
+ # 'deviceScreenSize' => '1080x2280' from get_android_capabilities; parsed into string-keyed hash
58
+ @mock_webdriver.expect(:capabilities, get_android_capabilities)
59
+ result = @android_metadata.device_screen_size
60
+ assert_equal({ 'width' => 1080, 'height' => 2280 }, result)
33
61
  @mock_webdriver.verify
34
62
  end
35
63
 
@@ -74,4 +102,28 @@ class TestAndroidMetadata < Minitest::Test
74
102
  def test_scale_factor
75
103
  assert_equal(1, @android_metadata.scale_factor)
76
104
  end
105
+
106
+ # Regression: appium_lib_core 13.x returns capabilities with snake_case keys
107
+ # (e.g. "device_screen_size" instead of "deviceScreenSize").
108
+ def test_device_screen_size_with_snake_case_caps
109
+ android_capabilities = get_android_capabilities
110
+ android_capabilities.delete('deviceScreenSize')
111
+ android_capabilities['device_screen_size'] = '1080x2280'
112
+ @mock_webdriver.expect(:capabilities, android_capabilities)
113
+
114
+ result = @android_metadata.device_screen_size
115
+ assert_equal({ 'width' => 1080, 'height' => 2280 }, result)
116
+ @mock_webdriver.verify
117
+ end
118
+
119
+ # Regression: the nested desired-caps hash is also normalized, so a
120
+ # snake_case "device_name" inside "desired" still resolves the device.
121
+ def test_device_name_with_snake_case_desired_caps
122
+ android_capabilities = get_android_capabilities
123
+ android_capabilities['desired'] = { 'device_name' => 'google pixel 4' }
124
+ @mock_webdriver.expect(:capabilities, android_capabilities)
125
+ @mock_webdriver.expect(:capabilities, android_capabilities)
126
+
127
+ assert_equal('google pixel 4', @android_metadata._device_name)
128
+ end
77
129
  end
@@ -67,15 +67,170 @@ class TestAppAutomate < Minitest::Test
67
67
  end
68
68
 
69
69
  def test_execute_percy_screenshot_end
70
- @app_automate.stub(:execute_percy_screenshot_begin, 'deviceName' => 'abc', 'osVersion' => '123') do
70
+ # Wrap the stub return hashes in explicit braces. Under Ruby 3 keyword
71
+ # argument separation, a bare trailing hash is otherwise parsed as keyword
72
+ # args to Minitest's stub, raising ArgumentError.
73
+ @app_automate.stub(:execute_percy_screenshot_begin, { 'deviceName' => 'abc', 'osVersion' => '123' }) do
71
74
  @app_automate.stub(:execute_percy_screenshot_end, nil) do
72
- @app_automate.stub(:screenshot, 'link' => 'https://link') do
75
+ @app_automate.stub(:screenshot, { 'link' => 'https://link' }) do
73
76
  @app_automate.screenshot('name')
74
77
  end
75
78
  end
76
79
  end
77
80
  end
78
81
 
82
+ # Covers app_automate.rb lines 34-36: the rescue branch in #screenshot where the
83
+ # super call fails, #execute_percy_screenshot_end is invoked with 'failure', and the
84
+ # error is re-raised.
85
+ def test_screenshot_failure_calls_end_and_reraises
86
+ captured = {}
87
+ @app_automate.stub(:execute_percy_screenshot_begin, nil) do
88
+ # super (GenericProvider#screenshot) calls _get_tiles first; make it blow up.
89
+ @app_automate.stub(:_get_tiles, ->(**_kwargs) { raise StandardError, 'boom' }) do
90
+ @app_automate.stub(:execute_percy_screenshot_end,
91
+ lambda do |name, url, status, sync = nil, message = nil|
92
+ captured[:name] = name
93
+ captured[:url] = url
94
+ captured[:status] = status
95
+ captured[:sync] = sync
96
+ captured[:message] = message
97
+ end) do
98
+ error = assert_raises(StandardError) { @app_automate.screenshot('failing-shot') }
99
+ assert_equal 'boom', error.message
100
+ end
101
+ end
102
+ end
103
+ assert_equal 'failing-shot', captured[:name]
104
+ assert_equal 'failure', captured[:status]
105
+ assert_equal 'boom', captured[:message]
106
+ end
107
+
108
+ # Covers app_automate.rb lines 24-26: when begin returns session details, the device
109
+ # metadata and debug url are set before the super screenshot (driven through the stubbed
110
+ # collaborators _get_tiles/_find_regions/_post_screenshots) succeeds.
111
+ def test_screenshot_success_sets_metadata_and_debug_url
112
+ session_details = {
113
+ 'deviceName' => 'Google Pixel 7',
114
+ 'osVersion' => '13.0',
115
+ 'buildHash' => 'bhash',
116
+ 'sessionHash' => 'shash'
117
+ }
118
+ post_response = { 'link' => 'https://link', 'data' => { 'ok' => true } }
119
+ @app_automate.stub(:execute_percy_screenshot_begin, session_details) do
120
+ @app_automate.stub(:_get_tiles, []) do
121
+ @app_automate.stub(:_get_tag, {}) do
122
+ @app_automate.stub(:_find_regions, []) do
123
+ @app_automate.stub(:_post_screenshots, post_response) do
124
+ @app_automate.stub(:execute_percy_screenshot_end, nil) do
125
+ result = @app_automate.screenshot('shot')
126
+ assert_equal({ 'ok' => true }, result)
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
133
+ assert_equal 'Google Pixel 7', @metadata.device_name
134
+ @mock_webdriver.expect(:capabilities, get_android_capabilities)
135
+ assert_equal '13', @metadata.os_version
136
+ assert_equal 'https://app-automate.browserstack.com/dashboard/v2/builds/bhash/sessions/shash',
137
+ @app_automate.get_debug_url
138
+ end
139
+
140
+ # Covers app_automate.rb line 50: when PERCY_DISABLE_REMOTE_UPLOADS=true and the
141
+ # screenshot is NOT fullpage, _get_tiles falls back to the generic provider's _get_tiles
142
+ # (run for real against the mock webdriver), returning a single file-backed tile.
143
+ def test_get_tiles_disable_remote_uploads_non_fullpage_falls_back_to_super
144
+ ENV['PERCY_DISABLE_REMOTE_UPLOADS'] = 'true'
145
+ @mock_webdriver.expect(:screenshot_as, 'png-bytes', [:png])
146
+
147
+ tiles = nil
148
+ @metadata.stub(:status_bar_height, 100) do
149
+ @metadata.stub(:navigation_bar_height, 150) do
150
+ tiles = @app_automate._get_tiles(fullpage: false)
151
+ end
152
+ end
153
+
154
+ assert_equal 1, tiles.length
155
+ dict_tile = tiles[0].to_h
156
+ assert_includes dict_tile, 'filepath'
157
+ assert(File.exist?(dict_tile['filepath']))
158
+ assert_equal 100, dict_tile['status_bar_height']
159
+ assert_equal 150, dict_tile['nav_bar_height']
160
+ File.delete(tiles[0].filepath)
161
+ ensure
162
+ ENV.delete('PERCY_DISABLE_REMOTE_UPLOADS')
163
+ end
164
+
165
+ # Covers app_automate.rb lines 49 (fullpage warning puts) and the path that does NOT
166
+ # fall back to super (line 50 skipped because fullpage_ss is true): full remote
167
+ # screenshot orchestration continues with PERCY_DISABLE_REMOTE_UPLOADS=true.
168
+ def test_get_tiles_disable_remote_uploads_fullpage_warns_and_continues
169
+ ENV['PERCY_DISABLE_REMOTE_UPLOADS'] = 'true'
170
+ metadata_mock = Minitest::Mock.new
171
+ metadata_mock.expect(:device_screen_size, { 'width' => 1080, 'height' => 1920 })
172
+ metadata_mock.expect(:scale_factor, 1)
173
+ metadata_mock.expect(:status_bar_height, 100)
174
+ metadata_mock.expect(:navigation_bar_height, 150)
175
+
176
+ @metadata.stub(:device_screen_size, { 'width' => 1080, 'height' => 1920 }) do
177
+ @metadata.stub(:scale_factor, 1) do
178
+ @metadata.stub(:status_bar_height, 100) do
179
+ @metadata.stub(:navigation_bar_height, 150) do
180
+ @app_automate.stub(:execute_percy_screenshot, {
181
+ 'result' => '[{"sha":"abc-1234","header_height":10,"footer_height":20}]'
182
+ }) do
183
+ tiles = @app_automate._get_tiles(fullpage: true)
184
+ assert_equal 1, tiles.length
185
+ assert_equal 'abc', tiles[0].sha
186
+ assert_equal 100, tiles[0].status_bar_height
187
+ assert_equal 150, tiles[0].nav_bar_height
188
+ assert_equal 10, tiles[0].header_height
189
+ assert_equal 20, tiles[0].footer_height
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
195
+ ensure
196
+ ENV.delete('PERCY_DISABLE_REMOTE_UPLOADS')
197
+ end
198
+
199
+ # Covers app_automate.rb lines 100-104: the rescue in #execute_percy_screenshot_begin
200
+ # when execute_script raises; it logs and returns nil.
201
+ def test_execute_percy_screenshot_begin_handles_error_returns_nil
202
+ @mock_webdriver.expect(:execute_script, nil) do
203
+ raise StandardError, 'begin failed'
204
+ end
205
+ result = @app_automate.execute_percy_screenshot_begin('Screenshot 1')
206
+ assert_nil result
207
+ @mock_webdriver.verify
208
+ end
209
+
210
+ # Covers app_automate.rb lines 123-125: the rescue in #execute_percy_screenshot_end
211
+ # when execute_script raises; it logs and swallows the error (returns nil).
212
+ def test_execute_percy_screenshot_end_handles_error
213
+ @mock_webdriver.expect(:execute_script, nil) do
214
+ raise StandardError, 'end failed'
215
+ end
216
+ result = @app_automate.execute_percy_screenshot_end('Screenshot 1', 'snapshot-url', 'success')
217
+ assert_nil result
218
+ @mock_webdriver.verify
219
+ end
220
+
221
+ # Covers app_automate.rb lines 156-159: the rescue in #execute_percy_screenshot when
222
+ # execute_script raises; it logs and re-raises the error.
223
+ def test_execute_percy_screenshot_handles_error_reraises
224
+ @mock_webdriver.expect(:execute_script, nil) do
225
+ raise StandardError, 'screenshot failed'
226
+ end
227
+ error = assert_raises(StandardError) do
228
+ @app_automate.execute_percy_screenshot(1080, 'singlepage', 5)
229
+ end
230
+ assert_equal 'screenshot failed', error.message
231
+ @mock_webdriver.verify
232
+ end
233
+
79
234
  def test_get_tiles
80
235
  # Mocking Percy::Metadata's session_id method
81
236
  metadata_mock = Minitest::Mock.new
data/specs/app_percy.rb CHANGED
@@ -136,6 +136,33 @@ class TestAppPercy < Minitest::Test
136
136
  end
137
137
  end
138
138
 
139
+ def test_throws_error_when_string_arg_type_mismatch
140
+ # Three constructions, each followed by a screenshot call with a
141
+ # non-String value for test_case / labels / th_test_case_execution_id.
142
+ 3.times do
143
+ @mock_android_webdriver.expect(:is_a?, true, [Appium::Core::Base::Driver])
144
+ end
145
+ 20.times do
146
+ @mock_android_webdriver.expect(:capabilities, get_android_capabilities)
147
+ end
148
+ 3.times do
149
+ @mock_android_webdriver.expect(:instance_variable_get, @bridge, [:@bridge])
150
+ @http.expect(:instance_variable_get, @server_url, [:@server_url])
151
+ @bridge.expect(:instance_variable_get, @http, [:@http])
152
+ @server_url.expect(:to_s, 'https://hub-cloud.browserstack.com/wd/hub')
153
+ end
154
+
155
+ assert_raises(TypeError) do
156
+ Percy::AppPercy.new(@mock_android_webdriver).screenshot('screenshot 1', test_case: 123)
157
+ end
158
+ assert_raises(TypeError) do
159
+ Percy::AppPercy.new(@mock_android_webdriver).screenshot('screenshot 1', labels: 123)
160
+ end
161
+ assert_raises(TypeError) do
162
+ Percy::AppPercy.new(@mock_android_webdriver).screenshot('screenshot 1', th_test_case_execution_id: 123)
163
+ end
164
+ end
165
+
139
166
  private
140
167
 
141
168
  def disable_percy_options(mock_webdriver, num = 1)
data/specs/cli_wrapper.rb CHANGED
@@ -16,6 +16,20 @@ def mock_poa_screenshot(fail: false)
16
16
  .to_return(body: "{\"success\": #{fail ? 'false, "error": "test"' : 'true'}}", status: (fail ? 500 : 200))
17
17
  end
18
18
 
19
+ def mock_healthcheck_with_version(version)
20
+ stub_request(:get, 'http://localhost:5338/percy/healthcheck')
21
+ .to_return(
22
+ body: JSON.dump(success: true, build: { 'id' => '123', 'url' => 'dummy_url' }, type: 'automate'),
23
+ status: 200,
24
+ headers: { 'X-Percy-Core-Version' => version }
25
+ )
26
+ end
27
+
28
+ def mock_failed_event(fail: false)
29
+ stub_request(:post, 'http://localhost:5338/percy/events')
30
+ .to_return(body: "{\"success\": #{fail ? 'false, "error": "test"' : 'true'}}", status: (fail ? 500 : 200))
31
+ end
32
+
19
33
  # Test suite for the Percy::CLIWrapper class
20
34
  class TestCLIWrapper < Minitest::Test
21
35
  def setup
@@ -143,4 +157,56 @@ class TestCLIWrapper < Minitest::Test
143
157
  assert_nil response['labels']
144
158
  assert_nil response['th_test_case_execution_id']
145
159
  end
160
+
161
+ # Targets cli_wrapper.rb lines 42-43: CLI minor version below 27 disables the SDK.
162
+ def test_percy_enabled_with_unsupported_minor_version
163
+ Percy::CLIWrapper.instance_variable_set(:@percy_enabled, nil)
164
+ mock_healthcheck_with_version('1.26.0')
165
+
166
+ assert_output(/Please upgrade to the latest CLI version/) do
167
+ assert_equal(false, Percy::CLIWrapper.percy_enabled?)
168
+ end
169
+ ensure
170
+ Percy::CLIWrapper.instance_variable_set(:@percy_enabled, nil)
171
+ end
172
+
173
+ # Targets cli_wrapper.rb lines 74, 79, 80, 89: post_failed_event success path
174
+ # builds the body, posts to /percy/events and parses the response.
175
+ def test_post_failed_event_success
176
+ mock_failed_event
177
+
178
+ result = Percy::CLIWrapper.post_failed_event('some error')
179
+
180
+ assert_equal({ 'success' => true }, result)
181
+ end
182
+
183
+ # Targets cli_wrapper.rb lines 83-86 (error branch) and 91-92 (rescue -> nil):
184
+ # a non-200 response raises CLIException internally which is rescued and nil returned.
185
+ def test_post_failed_event_handles_error_response
186
+ mock_failed_event(fail: true)
187
+
188
+ assert_nil Percy::CLIWrapper.post_failed_event('some error')
189
+ end
190
+
191
+ # Targets cli_wrapper.rb lines 91-92: a network/parse failure is rescued and nil returned.
192
+ def test_post_failed_event_rescues_exceptions
193
+ stub_request(:post, 'http://localhost:5338/percy/events').to_raise(StandardError.new('boom'))
194
+
195
+ assert_nil Percy::CLIWrapper.post_failed_event('some error')
196
+ end
197
+
198
+ # Targets cli_wrapper.rb lines 121-122: response is a 2xx success but not exactly 200,
199
+ # so the error message is extracted from the body and raised.
200
+ def test_post_poa_screenshots_raises_on_non_200_success
201
+ stub_request(:post, 'http://localhost:5338/percy/automateScreenshot')
202
+ .to_return(body: '{"success": false, "error": "poa error"}', status: 201)
203
+
204
+ error = assert_raises(CLIException) do
205
+ @cli_wrapper.post_poa_screenshots(
206
+ 'snapshot', 'session-id', 'http://example.com',
207
+ { 'browser' => 'chrome' }, { 'platform' => 'Windows' }, {}
208
+ )
209
+ end
210
+ assert_equal('poa error', error.message)
211
+ end
146
212
  end
@@ -5,6 +5,7 @@ require 'minitest/mock'
5
5
  require 'appium_lib'
6
6
 
7
7
  require_relative '../percy/metadata/driver_metadata'
8
+ require_relative '../percy/lib/cache'
8
9
 
9
10
  # Test suite for the Percy::DriverMetadata class
10
11
  class TestDriverMetadata < Minitest::Test
@@ -66,4 +67,25 @@ class TestDriverMetadata < Minitest::Test
66
67
 
67
68
  assert(session_caps, @metadata.session_capabilities)
68
69
  end
70
+
71
+ def test_session_capabilities_caches_desired_capabilities_on_cache_miss
72
+ # Force a clean cache and use a unique session id so the cache lookup misses
73
+ # and the desired_capabilities branch (set_cache) is exercised.
74
+ Percy::Cache.force_cleanup_cache
75
+ session_id = 'session_id_session_caps_miss'
76
+ desired_caps = {
77
+ 'platform' => 'chrome_android',
78
+ 'browserVersion' => '115.0.1',
79
+ 'session_name' => 'abc'
80
+ }
81
+ # session_id is read by session_capabilities, get_cache and set_cache.
82
+ 3.times { @mock_webdriver.expect(:session_id, session_id) }
83
+ @mock_webdriver.expect(:desired_capabilities, desired_caps)
84
+
85
+ fetched = @metadata.session_capabilities
86
+ assert_equal(desired_caps, fetched)
87
+ # Now the value is cached: read straight back without another driver call.
88
+ @mock_webdriver.expect(:session_id, session_id)
89
+ assert_equal(desired_caps, @metadata.session_capabilities)
90
+ end
69
91
  end
@@ -13,6 +13,13 @@ class TestGenericProvider < Minitest::Test
13
13
  include Appium
14
14
  COMPARISON_RESPONSE = { 'comparison' => { 'id' => 123, 'url' => 'https://percy-build-url' } }.freeze
15
15
 
16
+ # Some tests below permanently redefine Percy::Metadata#session_id and
17
+ # Percy::AndroidMetadata#get_system_bars via class_eval to route them at fixed-count
18
+ # mocks. Snapshot the pristine implementations once so teardown can restore them and the
19
+ # suite stays order-independent (the original suite is flaky on certain seeds otherwise).
20
+ PRISTINE_SESSION_ID = Percy::Metadata.instance_method(:session_id)
21
+ PRISTINE_GET_SYSTEM_BARS = Percy::AndroidMetadata.instance_method(:get_system_bars)
22
+
16
23
  def setup
17
24
  @existing_dir = 'existing-dir'
18
25
  teardown
@@ -37,6 +44,20 @@ class TestGenericProvider < Minitest::Test
37
44
  if Dir.exist?(@existing_dir)
38
45
  FileUtils.remove_dir(@existing_dir, force: true)
39
46
  end
47
+ restore_pristine_metadata_methods
48
+ end
49
+
50
+ # Undo the class_eval monkey-patching done by tests like test_post_screenshots and
51
+ # test_non_app_automate so subsequent tests see the real implementations.
52
+ def restore_pristine_metadata_methods
53
+ pristine_session_id = PRISTINE_SESSION_ID
54
+ pristine_get_system_bars = PRISTINE_GET_SYSTEM_BARS
55
+ Percy::Metadata.class_eval do
56
+ define_method(:session_id) { |*args, &blk| pristine_session_id.bind(self).call(*args, &blk) }
57
+ end
58
+ Percy::AndroidMetadata.class_eval do
59
+ define_method(:get_system_bars) { |*args, &blk| pristine_get_system_bars.bind(self).call(*args, &blk) }
60
+ end
40
61
  end
41
62
 
42
63
  def test_get_dir_without_env_variable
@@ -133,6 +154,30 @@ class TestGenericProvider < Minitest::Test
133
154
  File.delete(tile.filepath)
134
155
  end
135
156
 
157
+ # Covers generic_provider.rb line 75: requesting a fullpage screenshot on the generic
158
+ # provider logs the "only supported on App Automate" fallback message and still produces
159
+ # a single page tile.
160
+ def test_get_tiles_fullpage_logs_fallback
161
+ status_bar_height = 11
162
+ nav_bar_height = 22
163
+ logged = []
164
+ @generic_provider.stub(:log, ->(message, **_kwargs) { logged << message }) do
165
+ tile = @generic_provider._get_tiles(
166
+ fullpage: true,
167
+ status_bar_height: status_bar_height,
168
+ nav_bar_height: nav_bar_height
169
+ )[0]
170
+ dict_tile = tile.to_h
171
+ assert_includes dict_tile, 'filepath'
172
+ assert(File.exist?(dict_tile['filepath']))
173
+ assert_equal status_bar_height, dict_tile['status_bar_height']
174
+ assert_equal nav_bar_height, dict_tile['nav_bar_height']
175
+ File.delete(tile.filepath)
176
+ end
177
+ assert_includes logged,
178
+ 'Full page screenshot is only supported on App Automate. Falling back to single page screenshot.'
179
+ end
180
+
136
181
  def test_get_tiles_kwargs
137
182
  status_bar_height = 135
138
183
  nav_bar_height = 246
@@ -256,7 +301,7 @@ class TestGenericProvider < Minitest::Test
256
301
  end
257
302
 
258
303
  @mock_webdriver.expect(:find_element, mock_element,
259
- [Appium::Core::Base::SearchContext::FINDERS[:xpath], '//path/to/element'])
304
+ [:xpath, '//path/to/element'])
260
305
 
261
306
  elements_array = []
262
307
  xpaths = ['//path/to/element']
@@ -271,7 +316,7 @@ class TestGenericProvider < Minitest::Test
271
316
  end
272
317
 
273
318
  def test_get_regions_by_xpath_with_non_existing_element
274
- @mock_webdriver.expect(:find_element, [Appium::Core::Base::SearchContext::FINDERS[:xpath], '//path/to/element']) do
319
+ @mock_webdriver.expect(:find_element, [:xpath, '//path/to/element']) do
275
320
  raise Appium::Core::Error::NoSuchElementError, 'Test error'
276
321
  end
277
322
  elements_array = []
@@ -309,7 +354,7 @@ class TestGenericProvider < Minitest::Test
309
354
  mock_element.expect(:size, Selenium::WebDriver::Dimension.new(100, 200))
310
355
  end
311
356
 
312
- @mock_webdriver.expect(:find_element, [Appium::Core::Base::SearchContext::FINDERS[:accessibility_id], 'id1']) do
357
+ @mock_webdriver.expect(:find_element, [:accessibility_id, 'id1']) do
313
358
  raise Appium::Core::Error::NoSuchElementError, 'Test error'
314
359
  end
315
360
 
@@ -69,6 +69,25 @@ class TestIOSMetadata < Minitest::Test
69
69
  assert_equal({ 'height' => 40 }, status_bar)
70
70
  end
71
71
 
72
+ def test_status_bar_from_viewport_top
73
+ # Seed a viewport with a non-zero `top` so status_bar uses the viewport
74
+ # height (view_port['top']) instead of the static config scale_factor path.
75
+ Percy::Cache.force_cleanup_cache
76
+ session_id = 'session_id_viewport_top'
77
+ Percy::Cache.set_cache(session_id, Percy::Cache::VIEWPORT, { 'top' => 30, 'height' => 200, 'width' => 100 })
78
+
79
+ # status_bar -> viewport reads the seeded cache (1 session_id call); since
80
+ # top != 0 it returns view_port['top'] directly.
81
+ @mock_webdriver.expect(:session_id, session_id)
82
+
83
+ status_bar = @ios_metadata.status_bar
84
+ assert_equal({ 'height' => 30 }, status_bar)
85
+ end
86
+
87
+ def test_navigation_bar
88
+ assert_equal({ 'height' => 0 }, @ios_metadata.navigation_bar)
89
+ end
90
+
72
91
  def test_scale_factor_present_in_devices_json
73
92
  @mock_webdriver.expect(:capabilities, { 'deviceName' => 'iPhone 6' })
74
93
  assert_equal(2, @ios_metadata.scale_factor)
@@ -86,4 +105,15 @@ class TestIOSMetadata < Minitest::Test
86
105
 
87
106
  assert_equal(2, @ios_metadata.scale_factor)
88
107
  end
108
+
109
+ def test_device_name_with_camel_case_caps
110
+ @mock_webdriver.expect(:capabilities, { 'deviceName' => 'iPhone 14' })
111
+ assert_equal('iPhone 14', @ios_metadata.device_name)
112
+ end
113
+
114
+ # Regression: appium_lib_core 13.x returns capabilities with snake_case keys.
115
+ def test_device_name_with_snake_case_caps
116
+ @mock_webdriver.expect(:capabilities, { 'device_name' => 'iPhone 14' })
117
+ assert_equal('iPhone 14', @ios_metadata.device_name)
118
+ end
89
119
  end
data/specs/metadata.rb CHANGED
@@ -108,6 +108,16 @@ class TestMetadata < Minitest::Test
108
108
  assert_equal('10', os_ver)
109
109
  end
110
110
 
111
+ def test_metadata_os_version_returns_empty_string_on_error
112
+ # os_version that does not respond to #to_f (a Hash) makes the
113
+ # `os_version.to_f.to_i.to_s` conversion raise, exercising the rescue path.
114
+ capabilities = { 'os_version' => {} }
115
+ @mock_webdriver.expect(:capabilities, capabilities)
116
+ @mock_webdriver.expect(:capabilities, capabilities)
117
+ @mock_webdriver.expect(:capabilities, capabilities)
118
+ assert_equal('', @metadata.os_version)
119
+ end
120
+
111
121
  def test_metadata_value_from_devices_info_for_android
112
122
  android_device = 'google pixel 7'
113
123
  android_device_info = { '13' => { 'status_bar' => '118', 'nav_bar' => '63' } }
@@ -127,4 +137,26 @@ class TestMetadata < Minitest::Test
127
137
  @metadata.value_from_devices_info('scale_factor', ios_device)
128
138
  )
129
139
  end
140
+
141
+ def test_normalize_capability_key
142
+ # camelCase, snake_case, SCREAMING and the appium: vendor prefix all
143
+ # collapse to the same normalized key.
144
+ %w[platformName platform_name PLATFORM_NAME appium:platformName].each do |key|
145
+ assert_equal('platformname', Percy::Metadata.normalize_capability_key(key))
146
+ end
147
+ assert_equal('platformname', Percy::Metadata.normalize_capability_key(:platformName))
148
+ end
149
+
150
+ # Regression: appium_lib_core 13.x returns capabilities with snake_case keys.
151
+ def test_metadata_os_name_with_snake_case_caps
152
+ @mock_webdriver.expect(:capabilities, { 'platform_name' => 'Android' })
153
+ assert_equal('Android', @metadata.os_name)
154
+ end
155
+
156
+ def test_metadata_os_version_with_snake_case_platform_version
157
+ capabilities = { 'platform_version' => '14' }
158
+ @mock_webdriver.expect(:capabilities, capabilities)
159
+ @mock_webdriver.expect(:capabilities, capabilities)
160
+ assert_equal('14', @metadata.os_version)
161
+ end
130
162
  end
@@ -20,6 +20,24 @@ class MetadataResolverTestCase < Minitest::Test
20
20
  assert_instance_of(Percy::AndroidMetadata, resolved_metadata)
21
21
  @mock_webdriver.verify
22
22
  end
23
+
24
+ def test_android_resolved_with_PLATFORM_NAME
25
+ @mock_webdriver.expect(:capabilities, { 'PLATFORM_NAME' => 'Android' })
26
+ @mock_webdriver.expect(:capabilities, { 'PLATFORM_NAME' => 'Android' })
27
+ resolved_metadata = Percy::MetadataResolver.resolve(@mock_webdriver)
28
+
29
+ assert_instance_of(Percy::AndroidMetadata, resolved_metadata)
30
+ @mock_webdriver.verify
31
+ end
32
+
33
+ def test_android_resolved_with_platform_name
34
+ @mock_webdriver.expect(:capabilities, { 'platform_name' => 'Android' })
35
+ @mock_webdriver.expect(:capabilities, { 'platform_name' => 'Android' })
36
+ resolved_metadata = Percy::MetadataResolver.resolve(@mock_webdriver)
37
+
38
+ assert_instance_of(Percy::AndroidMetadata, resolved_metadata)
39
+ @mock_webdriver.verify
40
+ end
23
41
 
24
42
  def test_ios_resolved
25
43
  @mock_webdriver.expect(:capabilities, { 'platformName' => 'iOS' })
@@ -29,6 +47,23 @@ class MetadataResolverTestCase < Minitest::Test
29
47
  @mock_webdriver.verify
30
48
  end
31
49
 
50
+ def test_ios_resolved_with_platform_name
51
+ @mock_webdriver.expect(:capabilities, { 'platform_name' => 'iOS' })
52
+ resolved_metadata = Percy::MetadataResolver.resolve(@mock_webdriver)
53
+
54
+ assert_instance_of(Percy::IOSMetadata, resolved_metadata)
55
+ @mock_webdriver.verify
56
+ end
57
+
58
+ def test_platformName_precedence_over_platform_name
59
+ @mock_webdriver.expect(:capabilities, { 'platformName' => 'Android', 'platform_name' => 'iOS' })
60
+ @mock_webdriver.expect(:capabilities, { 'platformName' => 'Android', 'platform_name' => 'iOS' })
61
+ resolved_metadata = Percy::MetadataResolver.resolve(@mock_webdriver)
62
+
63
+ assert_instance_of(Percy::AndroidMetadata, resolved_metadata)
64
+ @mock_webdriver.verify
65
+ end
66
+
32
67
  def test_unknown_platform_exception
33
68
  @mock_webdriver.expect(:capabilities, { 'platformName' => 'Something Random' })
34
69
 
@@ -38,4 +73,14 @@ class MetadataResolverTestCase < Minitest::Test
38
73
 
39
74
  @mock_webdriver.verify
40
75
  end
76
+
77
+ def test_unknown_platform_exception_with_platform_name
78
+ @mock_webdriver.expect(:capabilities, { 'platform_name' => 'Something Random' })
79
+
80
+ assert_raises(PlatformNotSupported) do
81
+ Percy::MetadataResolver.resolve(@mock_webdriver)
82
+ end
83
+
84
+ @mock_webdriver.verify
85
+ end
41
86
  end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'minitest/autorun'
4
+ require 'json'
5
+ require 'webmock/minitest'
6
+ require 'appium_lib'
7
+
8
+ require_relative '../percy/lib/percy_automate'
9
+ require_relative '../percy/lib/percy_options'
10
+ require_relative '../percy/lib/cli_wrapper'
11
+ require_relative 'mocks/mock_methods'
12
+
13
+ def mock_poa_screenshot(fail: false)
14
+ stub_request(:post, 'http://localhost:5338/percy/automateScreenshot')
15
+ .to_return(body: "{\"success\": #{fail ? 'false, "error": "test"' : 'true'}}", status: (fail ? 500 : 200))
16
+ end
17
+
18
+ # Test suite for the Percy::PercyOnAutomate class
19
+ class TestPercyOnAutomate < Minitest::Test
20
+ def setup
21
+ @mock_driver = Minitest::Mock.new
22
+ @bridge = Minitest::Mock.new
23
+ @http = Minitest::Mock.new
24
+ @server_url = Minitest::Mock.new
25
+ WebMock.enable!
26
+ end
27
+
28
+ def teardown
29
+ WebMock.disable!
30
+ end
31
+
32
+ # Targets percy_automate.rb line 17: an unsupported driver raises DriverNotSupported.
33
+ def test_initialize_raises_for_unsupported_driver
34
+ assert_raises(DriverNotSupported) do
35
+ Percy::PercyOnAutomate.new(Object.new)
36
+ end
37
+ end
38
+
39
+ # Targets percy_automate.rb lines 56-58: when the screenshot post fails, the error
40
+ # is rescued and logged rather than propagated.
41
+ def test_screenshot_rescues_and_logs_on_failure
42
+ mock_poa_screenshot(fail: true)
43
+
44
+ @mock_driver.expect(:is_a?, true, [Appium::Core::Base::Driver])
45
+ @mock_driver.expect(:capabilities, { 'percy:options' => { 'enabled' => true } })
46
+
47
+ 5.times do
48
+ @mock_driver.expect(:session_id, 'Dummy_session_id')
49
+ end
50
+ @mock_driver.expect(:instance_variable_get, @bridge, [:@bridge])
51
+ @http.expect(:instance_variable_get, @server_url, [:@server_url])
52
+ @bridge.expect(:instance_variable_get, @http, [:@http])
53
+ @server_url.expect(:to_s, 'https://hub-cloud.browserstack.com/wd/hub')
54
+ @mock_driver.expect(:capabilities, { 'key' => 'value' })
55
+ @mock_driver.expect(:desired_capabilities, { 'key' => 'value' })
56
+
57
+ percy = Percy::PercyOnAutomate.new(@mock_driver)
58
+
59
+ result = nil
60
+ assert_output(/Could not take Screenshot 'Snapshot 1'/) do
61
+ result = percy.screenshot('Snapshot 1', options: {})
62
+ end
63
+ assert_nil result
64
+ end
65
+ end
data/specs/screenshot.rb CHANGED
@@ -383,7 +383,7 @@ class TestPercyScreenshot < Minitest::Test
383
383
  })
384
384
  end
385
385
  driver.expect(:find_element, mock_element,
386
- [Appium::Core::Base::SearchContext::FINDERS[:xpath], '//path/to/element'])
386
+ [:xpath, '//path/to/element'])
387
387
 
388
388
  percy_screenshot(driver, 'screenshot 1', ignore_regions_xpaths: xpaths)
389
389
 
@@ -416,4 +416,87 @@ class TestPercyScreenshot < Minitest::Test
416
416
  mock_healthcheck(type: 'automate')
417
417
  assert_raises(Exception) { percy_screenshot(@mock_webdriver) }
418
418
  end
419
+
420
+ def test_swallows_screenshot_error_when_ignore_errors_true
421
+ mock_healthcheck
422
+ # /percy/events is hit by post_failed_event in the rescue path.
423
+ stub_request(:post, 'http://localhost:5338/percy/events')
424
+ .to_return(status: 200, body: '{"success": true}')
425
+
426
+ # Force the AppPercy (non-automate) provider so the rescue path in
427
+ # percy-appium-app.rb is exercised. session_type is memoized across tests.
428
+ Percy::Environment.session_type = nil
429
+
430
+ # Android caps have percy:options.ignoreErrors => true, so the error is
431
+ # swallowed and percy_screenshot returns nil. A non-String name makes
432
+ # AppPercy#screenshot raise a TypeError after construction succeeds.
433
+ driver = build_app_driver(get_android_capabilities)
434
+
435
+ result = nil
436
+ assert_output(/Could not take screenshot/) do
437
+ result = percy_screenshot(driver, 12_345)
438
+ end
439
+ assert_nil result
440
+ assert(@requests.any? { |r| r.uri.path == '/percy/events' })
441
+ end
442
+
443
+ def test_reraises_screenshot_error_when_ignore_errors_false
444
+ mock_healthcheck
445
+ stub_request(:post, 'http://localhost:5338/percy/events')
446
+ .to_return(status: 200, body: '{"success": true}')
447
+
448
+ Percy::Environment.session_type = nil
449
+
450
+ # Build caps whose percy:options.ignoreErrors is false so the rescue path
451
+ # re-raises after posting the failed event.
452
+ caps = get_android_capabilities.dup
453
+ caps['percy:options'] = { 'enabled' => true, 'ignoreErrors' => false }
454
+ caps['percyOptions'] = { 'enabled' => true, 'ignoreErrors' => false }
455
+ driver = build_app_driver(caps)
456
+
457
+ assert_output(/Could not take screenshot/) do
458
+ assert_raises(TypeError) { percy_screenshot(driver, 12_345) }
459
+ end
460
+ assert(@requests.any? { |r| r.uri.path == '/percy/events' })
461
+ end
462
+
463
+ private
464
+
465
+ # Builds a Minitest::Mock driver that survives AppPercy construction
466
+ # (is_a? Appium driver, capabilities, remote_url) for the rescue-path tests.
467
+ def build_app_driver(caps)
468
+ driver = Minitest::Mock.new
469
+ bridge = Minitest::Mock.new
470
+ http = Minitest::Mock.new
471
+ server_url = Minitest::Mock.new
472
+
473
+ driver.expect(:is_a?, true, [Appium::Core::Base::Driver])
474
+ 20.times { driver.expect(:capabilities, caps) }
475
+ 3.times do
476
+ driver.expect(:instance_variable_get, bridge, [:@bridge])
477
+ http.expect(:instance_variable_get, server_url, [:@server_url])
478
+ bridge.expect(:instance_variable_get, http, [:@http])
479
+ server_url.expect(:to_s, 'https://hub-cloud.browserstack.com/wd/hub')
480
+ end
481
+ driver
482
+ end
483
+ end
484
+
485
+ # Test suite for the `hashed` helper in percy/common/common.rb
486
+ class TestHashedHelper < Minitest::Test
487
+ # Stand-in object that responds to #as_json (core objects do not in this env).
488
+ class JsonableDouble
489
+ def as_json
490
+ { 'converted' => true }
491
+ end
492
+ end
493
+
494
+ def test_hashed_returns_hash_unchanged
495
+ input = { 'a' => 1 }
496
+ assert_same(input, hashed(input))
497
+ end
498
+
499
+ def test_hashed_converts_non_hash_via_as_json
500
+ assert_equal({ 'converted' => true }, hashed(JsonableDouble.new))
501
+ end
419
502
  end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Collates the per-process SimpleCov results written during the spec loop and
4
+ # enforces the coverage gate. Run after all specs:
5
+ # bundle exec ruby specs/support/coverage_check.rb
6
+ require 'simplecov'
7
+
8
+ SimpleCov.collate Dir['coverage/.resultset.json'] do
9
+ add_filter '/specs/'
10
+ track_files 'percy/**/*.rb'
11
+ minimum_coverage line: 100
12
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Per-spec runner used by CI: `bundle exec ruby specs/support/run_spec.rb specs/<file>.rb`.
4
+ # Requires spec_helper first (starting SimpleCov within the bundler context, which
5
+ # a command-line `-r` flag cannot do reliably) and then loads the target spec.
6
+ require_relative 'spec_helper'
7
+
8
+ spec_file = ARGV[0] or abort('usage: run_spec.rb <spec_file>')
9
+ load File.expand_path(spec_file)
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Loaded (via `ruby -r ./specs/support/spec_helper`) before each spec so
4
+ # SimpleCov can instrument the percy source. The suite runs one spec per
5
+ # process, so each process records its own result under a unique command_name;
6
+ # SimpleCov merges them into coverage/.resultset.json, and
7
+ # specs/support/coverage_check.rb collates the merged result and enforces the gate.
8
+ require 'simplecov'
9
+
10
+ SimpleCov.start do
11
+ add_filter '/specs/'
12
+ track_files 'percy/**/*.rb'
13
+ command_name "specs:#{File.basename(ARGV.first || $PROGRAM_NAME)}"
14
+ merge_timeout 3600
15
+ end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: percy-appium-app
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - BroswerStack
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2024-10-28 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: appium_lib
@@ -178,8 +177,12 @@ files:
178
177
  - specs/metadata.rb
179
178
  - specs/metadata_resolver.rb
180
179
  - specs/mocks/mock_methods.rb
180
+ - specs/percy_automate.rb
181
181
  - specs/percy_options.rb
182
182
  - specs/screenshot.rb
183
+ - specs/support/coverage_check.rb
184
+ - specs/support/run_spec.rb
185
+ - specs/support/spec_helper.rb
183
186
  - specs/tile.rb
184
187
  homepage: ''
185
188
  licenses:
@@ -187,7 +190,6 @@ licenses:
187
190
  metadata:
188
191
  bug_tracker_uri: https://github.com/percy/percy-appium-ruby/issues
189
192
  source_code_uri: https://github.com/percy/percy-appium-ruby
190
- post_install_message:
191
193
  rdoc_options: []
192
194
  require_paths:
193
195
  - percy
@@ -202,8 +204,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
202
204
  - !ruby/object:Gem::Version
203
205
  version: '0'
204
206
  requirements: []
205
- rubygems_version: 3.1.4
206
- signing_key:
207
+ rubygems_version: 3.6.9
207
208
  specification_version: 4
208
209
  summary: Percy visual testing for Ruby Appium Mobile Apps
209
210
  test_files: []