percy-selenium 1.1.1 → 1.1.2

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.
@@ -73,9 +73,43 @@ RSpec.describe Percy, type: :feature do
73
73
  expect { Percy.snapshot(page) }.to raise_error(ArgumentError)
74
74
  end
75
75
 
76
- it 'logs an error when sending a snapshot fails' do
76
+ it 'raises when session_type is automate' do
77
77
  stub_request(:get, "#{Percy::PERCY_SERVER_ADDRESS}/percy/healthcheck")
78
- .to_return(status: 200, body: '{"success": "true" }',
78
+ .to_return(
79
+ status: 200,
80
+ body: '{"success":true,"type":"automate"}',
81
+ headers: {'x-percy-core-version': '1.0.0'},
82
+ )
83
+
84
+ expect { Percy.snapshot(page, 'Name') }
85
+ .to raise_error(StandardError, /Invalid function call - percy_snapshot\(\)/)
86
+ end
87
+
88
+ it 'does not raise when older CLI omits type from healthcheck' do
89
+ stub_request(:get, "#{Percy::PERCY_SERVER_ADDRESS}/percy/healthcheck")
90
+ .to_return(
91
+ status: 200,
92
+ body: '{"success":true}',
93
+ headers: {'x-percy-core-version': '1.0.0'},
94
+ )
95
+
96
+ stub_request(:get, "#{Percy::PERCY_SERVER_ADDRESS}/percy/dom.js")
97
+ .to_return(
98
+ status: 200,
99
+ body: fetch_script_string,
100
+ headers: {},
101
+ )
102
+
103
+ stub_request(:post, 'http://localhost:5338/percy/snapshot')
104
+ .to_return(status: 200, body: '{"success":true}', headers: {})
105
+
106
+ visit 'index.html'
107
+ expect { Percy.snapshot(page, 'Name') }.to_not raise_error
108
+ end
109
+
110
+ it 'logs an error when sending a snapshot fails' do
111
+ stub_request(:get, "#{Percy::PERCY_SERVER_ADDRESS}/percy/healthcheck")
112
+ .to_return(status: 200, body: '{"success":true}',
79
113
  headers: {'x-percy-core-version': '1.0.0'},)
80
114
 
81
115
  stub_request(:get, "#{Percy::PERCY_SERVER_ADDRESS}/percy/dom.js")
@@ -94,7 +128,7 @@ RSpec.describe Percy, type: :feature do
94
128
 
95
129
  it 'sends snapshots to the local server' do
96
130
  stub_request(:get, "#{Percy::PERCY_SERVER_ADDRESS}/percy/healthcheck")
97
- .to_return(status: 200, body: '{"success": "true" }', headers: {
131
+ .to_return(status: 200, body: '{"success":true}', headers: {
98
132
  'x-percy-core-version': '1.0.0',
99
133
  },)
100
134
 
@@ -106,7 +140,7 @@ RSpec.describe Percy, type: :feature do
106
140
  )
107
141
 
108
142
  stub_request(:post, 'http://localhost:5338/percy/snapshot')
109
- .to_return(status: 200, body: '{"success": "true" }', headers: {})
143
+ .to_return(status: 200, body: '{"success":true}', headers: {})
110
144
 
111
145
  visit 'index.html'
112
146
  data = Percy.snapshot(page, 'Name', widths: [944])
@@ -130,7 +164,7 @@ RSpec.describe Percy, type: :feature do
130
164
  it 'sends multiple dom snapshots to the local server' do
131
165
  stub_request(:get, "#{Percy::PERCY_SERVER_ADDRESS}/percy/healthcheck").to_return(
132
166
  status: 200,
133
- body: '{"success": "true", "widths": { "mobile": [390], "config": [765, 1280]} }',
167
+ body: '{"success":true, "widths": { "mobile": [390], "config": [765, 1280]} }',
134
168
  headers: {
135
169
  'x-percy-core-version': '1.0.0',
136
170
  'config': {}, 'widths': {'mobile': [375], 'config': [765, 1280]},
@@ -144,8 +178,15 @@ RSpec.describe Percy, type: :feature do
144
178
  headers: {},
145
179
  )
146
180
 
181
+ stub_request(:get, "#{Percy::PERCY_SERVER_ADDRESS}/percy/widths-config")
182
+ .to_return(
183
+ status: 200,
184
+ body: {widths: [{width: 390}, {width: 765}, {width: 1280}]}.to_json,
185
+ headers: {},
186
+ )
187
+
147
188
  stub_request(:post, 'http://localhost:5338/percy/snapshot')
148
- .to_return(status: 200, body: '{"success": "true" }', headers: {})
189
+ .to_return(status: 200, body: '{"success":true}', headers: {})
149
190
 
150
191
  visit 'index.html'
151
192
  data = Percy.snapshot(page, 'Name', {responsive_snapshot_capture: true})
@@ -172,7 +213,7 @@ RSpec.describe Percy, type: :feature do
172
213
  it 'sends multiple dom snapshots to the local server using selenium' do
173
214
  stub_request(:get, "#{Percy::PERCY_SERVER_ADDRESS}/percy/healthcheck").to_return(
174
215
  status: 200,
175
- body: '{"success": "true", "widths": { "mobile": [390], "config": [765, 1280]} }',
216
+ body: '{"success":true, "widths": { "mobile": [390], "config": [765, 1280]} }',
176
217
  headers: {
177
218
  'x-percy-core-version': '1.0.0',
178
219
  'config': {}, 'widths': {'mobile': [375], 'config': [765, 1280]},
@@ -186,41 +227,46 @@ RSpec.describe Percy, type: :feature do
186
227
  headers: {},
187
228
  )
188
229
 
189
- stub_request(:post, 'http://localhost:5338/percy/snapshot')
190
- .to_return(status: 200, body: '{"success": "true" }', headers: {})
191
-
192
- driver = Selenium::WebDriver.for :firefox
230
+ stub_request(:get, "#{Percy::PERCY_SERVER_ADDRESS}/percy/widths-config")
231
+ .to_return(
232
+ status: 200,
233
+ body: {widths: [{width: 390}, {width: 765}, {width: 1280}]}.to_json,
234
+ headers: {},
235
+ )
193
236
 
194
- driver.navigate.to 'http://localhost:5338/test/snapshot'
195
- driver.manage.add_cookie({name: 'cookie-name', value: 'cookie-value'})
196
- data = Percy.snapshot(driver, 'Name', {responsive_snapshot_capture: true})
237
+ received_body = nil
238
+ stub_request(:post, 'http://localhost:5338/percy/snapshot').to_return do |request|
239
+ received_body = JSON.parse(request.body)
240
+ {status: 200, body: '{"success":true}', headers: {}}
241
+ end
197
242
 
198
- expected_cookie = {name: 'cookie-name', value: 'cookie-value', path: '/',
199
- domain: 'localhost', "expires": nil, "same_site": 'Lax',
200
- "http_only": false, "secure": false,}
201
- expected_dom = '<html><head></head><body><p>Snapshot Me!</p></body></html>'
202
- expect(WebMock).to have_requested(:post, "#{Percy::PERCY_SERVER_ADDRESS}/percy/snapshot")
203
- .with(
204
- body: {
205
- name: 'Name',
206
- url: 'http://localhost:5338/test/snapshot',
207
- dom_snapshot: [
208
- {'cookies': [expected_cookie], 'html': expected_dom, 'width': 390},
209
- {'cookies': [expected_cookie], 'html': expected_dom, 'width': 765},
210
- {'cookies': [expected_cookie], 'html': expected_dom, 'width': 1280},
211
- ],
212
- client_info: "percy-selenium-ruby/#{Percy::VERSION}",
213
- environment_info: "selenium/#{Selenium::WebDriver::VERSION} ruby/#{RUBY_VERSION}",
214
- responsive_snapshot_capture: true,
215
- }.to_json,
216
- ).once
243
+ driver = Selenium::WebDriver.for :firefox
244
+ begin
245
+ # Use the Capybara fixture server (already running for this describe block)
246
+ # instead of the percy test-mode server endpoint which is not available under
247
+ # normal percy exec.
248
+ driver.navigate.to 'http://127.0.0.1:3003/index.html'
249
+ driver.manage.add_cookie({name: 'cookie-name', value: 'cookie-value'})
250
+ data = Percy.snapshot(driver, 'Name', {responsive_snapshot_capture: true})
217
251
 
218
- expect(data).to eq(nil)
252
+ expect(received_body['name']).to eq('Name')
253
+ expect(received_body['url']).to eq('http://127.0.0.1:3003/index.html')
254
+ expect(received_body['dom_snapshot'].length).to eq(3)
255
+ expect(received_body['dom_snapshot'].map { |s| s['width'] }).to eq([390, 765, 1280])
256
+ expect(received_body['dom_snapshot'].first['cookies'].first['name']).to eq('cookie-name')
257
+ expect(data).to eq(nil)
258
+ ensure
259
+ begin
260
+ driver.quit
261
+ rescue StandardError
262
+ nil
263
+ end
264
+ end
219
265
  end
220
266
 
221
267
  it 'sends snapshots for sync' do
222
268
  stub_request(:get, "#{Percy::PERCY_SERVER_ADDRESS}/percy/healthcheck")
223
- .to_return(status: 200, body: '{"success": "true" }',
269
+ .to_return(status: 200, body: '{"success":true}',
224
270
  headers: {'x-percy-core-version': '1.0.0'},)
225
271
 
226
272
  stub_request(:get, "#{Percy::PERCY_SERVER_ADDRESS}/percy/dom.js")
@@ -231,7 +277,7 @@ RSpec.describe Percy, type: :feature do
231
277
  )
232
278
 
233
279
  stub_request(:post, 'http://localhost:5338/percy/snapshot')
234
- .to_return(status: 200, body: '{"success": "true", "data": "sync_data" }', headers: {})
280
+ .to_return(status: 200, body: '{"success":true, "data": "sync_data"}', headers: {})
235
281
 
236
282
  visit 'index.html'
237
283
  data = Percy.snapshot(page, 'Name', {sync: true})
@@ -304,6 +350,18 @@ RSpec.describe Percy do
304
350
  expect(region[:assertion][:diffIgnoreThreshold]).to eq(0.2)
305
351
  end
306
352
 
353
+ it 'creates a region with configuration settings when algorithm is intelliignore' do
354
+ region = Percy.create_region(
355
+ algorithm: 'intelliignore',
356
+ diff_sensitivity: 0.8,
357
+ carousels_enabled: true,
358
+ )
359
+
360
+ expect(region[:algorithm]).to eq('intelliignore')
361
+ expect(region[:configuration][:diffSensitivity]).to eq(0.8)
362
+ expect(region[:configuration][:carouselsEnabled]).to eq(true)
363
+ end
364
+
307
365
  it 'does not add empty configuration or assertion keys' do
308
366
  region = Percy.create_region(algorithm: 'ignore')
309
367
  expect(region).to_not have_key(:configuration)
@@ -312,6 +370,642 @@ RSpec.describe Percy do
312
370
  end
313
371
  end
314
372
 
373
+ RSpec.describe Percy do
374
+ before(:each) do
375
+ WebMock.disable_net_connect!(allow: '127.0.0.1')
376
+ stub_request(:post, 'http://localhost:5338/percy/log').to_raise(StandardError)
377
+ Percy._clear_cache!
378
+ end
379
+
380
+ describe '.get_responsive_widths' do
381
+ it 'fetches widths from the /percy/widths-config endpoint' do
382
+ stub_request(:get, "#{Percy::PERCY_SERVER_ADDRESS}/percy/widths-config")
383
+ .to_return(status: 200, body: {widths: [{'width' => 375}, {'width' => 768}]}.to_json)
384
+
385
+ result = Percy.get_responsive_widths
386
+ expect(result).to eq([{'width' => 375}, {'width' => 768}])
387
+ end
388
+
389
+ it 'passes user-supplied widths as a query parameter' do
390
+ stub_request(:get, "#{Percy::PERCY_SERVER_ADDRESS}/percy/widths-config?widths=375,1280")
391
+ .to_return(status: 200, body: {widths: [{'width' => 375}, {'width' => 1280}]}.to_json)
392
+
393
+ result = Percy.get_responsive_widths([375, 1280])
394
+ expect(result).to eq([{'width' => 375}, {'width' => 1280}])
395
+ end
396
+
397
+ it 'omits query param when widths array is empty' do
398
+ stub_request(:get, "#{Percy::PERCY_SERVER_ADDRESS}/percy/widths-config")
399
+ .to_return(status: 200, body: {widths: [{'width' => 1280}]}.to_json)
400
+
401
+ result = Percy.get_responsive_widths([])
402
+ expect(result).to eq([{'width' => 1280}])
403
+ end
404
+
405
+ it 'raises when the response widths key is not an array' do
406
+ stub_request(:get, "#{Percy::PERCY_SERVER_ADDRESS}/percy/widths-config")
407
+ .to_return(status: 200, body: {widths: nil}.to_json)
408
+
409
+ cli_error = 'Update Percy CLI to the latest version to use responsiveSnapshotCapture'
410
+ expect { Percy.get_responsive_widths }.to raise_error(StandardError, cli_error)
411
+ end
412
+
413
+ it 'raises when the HTTP request fails' do
414
+ stub_request(:get, "#{Percy::PERCY_SERVER_ADDRESS}/percy/widths-config")
415
+ .to_return(status: 500, body: '')
416
+
417
+ cli_error = 'Update Percy CLI to the latest version to use responsiveSnapshotCapture'
418
+ expect { Percy.get_responsive_widths }.to raise_error(StandardError, cli_error)
419
+ end
420
+
421
+ it 'raises when the endpoint is unreachable' do
422
+ stub_request(:get, "#{Percy::PERCY_SERVER_ADDRESS}/percy/widths-config")
423
+ .to_raise(StandardError, 'connection refused')
424
+
425
+ cli_error = 'Update Percy CLI to the latest version to use responsiveSnapshotCapture'
426
+ expect { Percy.get_responsive_widths }.to raise_error(StandardError, cli_error)
427
+ end
428
+ end
429
+
430
+ describe '.unsupported_iframe_src?' do
431
+ it 'returns true for nil src' do
432
+ expect(Percy.unsupported_iframe_src?(nil)).to be true
433
+ end
434
+
435
+ it 'returns true for empty string src' do
436
+ expect(Percy.unsupported_iframe_src?('')).to be true
437
+ end
438
+
439
+ it 'returns true for about:blank' do
440
+ expect(Percy.unsupported_iframe_src?('about:blank')).to be true
441
+ end
442
+
443
+ it 'returns true for javascript: src' do
444
+ expect(Percy.unsupported_iframe_src?('javascript:void(0)')).to be true
445
+ end
446
+
447
+ it 'returns true for data: src' do
448
+ expect(Percy.unsupported_iframe_src?('data:text/html,<h1>Hi</h1>')).to be true
449
+ end
450
+
451
+ it 'returns true for vbscript: src' do
452
+ expect(Percy.unsupported_iframe_src?('vbscript:MsgBox()')).to be true
453
+ end
454
+
455
+ it 'returns false for a valid https url' do
456
+ expect(Percy.unsupported_iframe_src?('https://example.com/page')).to be false
457
+ end
458
+
459
+ it 'returns false for a relative url' do
460
+ expect(Percy.unsupported_iframe_src?('/embed.html')).to be false
461
+ end
462
+ end
463
+
464
+ describe '.get_origin' do
465
+ it 'returns scheme + host for an https url' do
466
+ expect(Percy.get_origin('https://example.com/path')).to eq('https://example.com')
467
+ end
468
+
469
+ it 'returns scheme + host for an http url' do
470
+ expect(Percy.get_origin('http://example.com/path')).to eq('http://example.com')
471
+ end
472
+
473
+ it 'omits the default http port 80' do
474
+ expect(Percy.get_origin('http://example.com:80/path')).to eq('http://example.com')
475
+ end
476
+
477
+ it 'omits the default https port 443' do
478
+ expect(Percy.get_origin('https://example.com:443/path')).to eq('https://example.com')
479
+ end
480
+
481
+ it 'includes non-default ports' do
482
+ expect(Percy.get_origin('http://example.com:3000/path')).to eq('http://example.com:3000')
483
+ end
484
+
485
+ it 'treats same host with different scheme as different origins' do
486
+ http = Percy.get_origin('http://example.com')
487
+ https = Percy.get_origin('https://example.com')
488
+ expect(http).to_not eq(https)
489
+ end
490
+
491
+ it 'treats same host with different ports as different origins' do
492
+ port_a = Percy.get_origin('http://example.com:3000')
493
+ port_b = Percy.get_origin('http://example.com:4000')
494
+ expect(port_a).to_not eq(port_b)
495
+ end
496
+ end
497
+
498
+ describe '.process_frame' do
499
+ let(:driver) { double('driver') }
500
+ let(:frame_element) { double('frame_element') }
501
+ let(:switch_to) { double('switch_to') }
502
+
503
+ before(:each) do
504
+ allow(driver).to receive(:switch_to).and_return(switch_to)
505
+ allow(switch_to).to receive(:frame)
506
+ allow(switch_to).to receive(:parent_frame)
507
+ allow(switch_to).to receive(:default_content)
508
+ end
509
+
510
+ it 'returns a hash with iframeData, iframeSnapshot, and frameUrl on success' do
511
+ allow(frame_element).to receive(:attribute).with('src')
512
+ .and_return('https://other.example.com/page')
513
+ allow(frame_element).to receive(:attribute).with('data-percy-element-id')
514
+ .and_return('elem-123')
515
+ allow(driver).to receive(:execute_script).and_return(nil, {'html' => '<html/>'})
516
+
517
+ result = Percy.process_frame(driver, frame_element, {}, 'percy_dom_script')
518
+
519
+ expect(result).to_not be_nil
520
+ expect(result['iframeData']['percyElementId']).to eq('elem-123')
521
+ expect(result['iframeSnapshot']).to eq({'html' => '<html/>'})
522
+ expect(result['frameUrl']).to eq('https://other.example.com/page')
523
+ end
524
+
525
+ it 'returns nil when data-percy-element-id attribute is missing' do
526
+ allow(frame_element).to receive(:attribute).with('src').and_return('https://other.example.com/page')
527
+ allow(frame_element).to receive(:attribute).with('data-percy-element-id').and_return(nil)
528
+ allow(driver).to receive(:execute_script).and_return(nil, {'html' => '<html/>'})
529
+
530
+ result = Percy.process_frame(driver, frame_element, {}, 'percy_dom_script')
531
+ expect(result).to be_nil
532
+ end
533
+
534
+ it 'returns nil when execute_script raises inside the iframe' do
535
+ allow(frame_element).to receive(:attribute).with('src').and_return('https://other.example.com/page')
536
+ allow(driver).to receive(:execute_script).and_raise(StandardError, 'injection error')
537
+
538
+ result = Percy.process_frame(driver, frame_element, {}, 'percy_dom_script')
539
+ expect(result).to be_nil
540
+ end
541
+
542
+ it 'returns nil when switching to the frame fails' do
543
+ allow(frame_element).to receive(:attribute).with('src').and_return('https://other.example.com/page')
544
+ allow(switch_to).to receive(:frame).and_raise(StandardError, 'no such frame')
545
+
546
+ result = Percy.process_frame(driver, frame_element, {}, 'percy_dom_script')
547
+ expect(result).to be_nil
548
+ end
549
+
550
+ it 'uses unknown-src fallback when frame has no src attribute' do
551
+ allow(frame_element).to receive(:attribute).with('src').and_return(nil)
552
+ allow(frame_element).to receive(:attribute).with('data-percy-element-id')
553
+ .and_return('elem-nosrc')
554
+ allow(driver).to receive(:execute_script).and_return(nil, {'html' => '<html/>'})
555
+
556
+ result = Percy.process_frame(driver, frame_element, {}, 'percy_dom_script')
557
+ expect(result['frameUrl']).to eq('unknown-src')
558
+ end
559
+
560
+ it 'merges enableJavaScript into the PercyDOM.serialize call' do
561
+ allow(frame_element).to receive(:attribute).with('src')
562
+ .and_return('https://other.example.com/page')
563
+ allow(frame_element).to receive(:attribute).with('data-percy-element-id')
564
+ .and_return('elem-abc')
565
+
566
+ captured_serialize_call = nil
567
+ call_count = 0
568
+ allow(driver).to receive(:execute_script) do |script|
569
+ call_count += 1
570
+ if call_count == 2
571
+ captured_serialize_call = script
572
+ {'html' => '<html/>'}
573
+ end
574
+ end
575
+
576
+ Percy.process_frame(driver, frame_element, {someOpt: 1}, 'percy_dom_script')
577
+
578
+ expect(captured_serialize_call).to include('enableJavaScript')
579
+ expect(captured_serialize_call).to include('true')
580
+ end
581
+
582
+ it 'always switches back to default content even when script injection fails' do
583
+ allow(frame_element).to receive(:attribute).with('src')
584
+ .and_return('https://other.example.com/page')
585
+ expect(switch_to).to receive(:default_content).once
586
+ allow(driver).to receive(:execute_script).and_raise(StandardError, 'error')
587
+
588
+ Percy.process_frame(driver, frame_element, {}, 'percy_dom_script')
589
+ end
590
+ end
591
+
592
+ describe '.get_serialized_dom' do
593
+ let(:driver) { double('driver') }
594
+ let(:manage) { double('manage') }
595
+ let(:switch_to) { double('switch_to') }
596
+
597
+ before(:each) do
598
+ allow(driver).to receive(:manage).and_return(manage)
599
+ allow(manage).to receive(:all_cookies).and_return([])
600
+ allow(driver).to receive(:switch_to).and_return(switch_to)
601
+ allow(switch_to).to receive(:frame)
602
+ allow(switch_to).to receive(:parent_frame)
603
+ allow(switch_to).to receive(:default_content)
604
+ end
605
+
606
+ it 'returns the serialized dom with cookies when no iframes present' do
607
+ allow(driver).to receive(:execute_script).and_return({'html' => '<html/>'})
608
+ allow(driver).to receive(:current_url).and_return('http://main.example.com/')
609
+ allow(driver).to receive(:find_elements).and_return([])
610
+
611
+ dom = Percy.get_serialized_dom(driver, {})
612
+ expect(dom['html']).to eq('<html/>')
613
+ expect(dom['cookies']).to eq([])
614
+ expect(dom).to_not have_key('corsIframes')
615
+ end
616
+
617
+ it 'populates corsIframes for cross-origin frames' do
618
+ frame = double('frame')
619
+ allow(frame).to receive(:attribute).with('src').and_return('https://cross.example.com/page')
620
+ allow(frame).to receive(:attribute).with('data-percy-element-id').and_return('cid-1')
621
+
622
+ call_count = 0
623
+ allow(driver).to receive(:execute_script) do
624
+ call_count += 1
625
+ case call_count
626
+ when 1 then {'html' => '<main/>'}
627
+ when 2 then nil
628
+ when 3 then {'html' => '<frame/>'}
629
+ end
630
+ end
631
+ allow(driver).to receive(:current_url).and_return('http://main.example.com/')
632
+ allow(driver).to receive(:find_elements).and_return([frame])
633
+
634
+ dom = Percy.get_serialized_dom(driver, {}, percy_dom_script: 'percy_dom_script')
635
+
636
+ expect(dom).to have_key('corsIframes')
637
+ expect(dom['corsIframes'].length).to eq(1)
638
+ expect(dom['corsIframes'][0]['iframeData']['percyElementId']).to eq('cid-1')
639
+ expect(dom['corsIframes'][0]['iframeSnapshot']['html']).to eq('<frame/>')
640
+ expect(dom['corsIframes'][0]['frameUrl']).to eq('https://cross.example.com/page')
641
+ end
642
+
643
+ it 'skips same-origin iframes' do
644
+ frame = double('frame')
645
+ allow(frame).to receive(:attribute).with('src').and_return('http://main.example.com/inner.html')
646
+ allow(driver).to receive(:execute_script).and_return({'html' => '<html/>'})
647
+ allow(driver).to receive(:current_url).and_return('http://main.example.com/')
648
+ allow(driver).to receive(:find_elements).and_return([frame])
649
+
650
+ dom = Percy.get_serialized_dom(driver, {}, percy_dom_script: 'percy_dom_script')
651
+ expect(dom).to_not have_key('corsIframes')
652
+ end
653
+
654
+ it 'skips iframes with about:blank src' do
655
+ frame = double('frame')
656
+ allow(frame).to receive(:attribute).with('src').and_return('about:blank')
657
+ allow(driver).to receive(:execute_script).and_return({'html' => '<html/>'})
658
+ allow(driver).to receive(:current_url).and_return('http://main.example.com/')
659
+ allow(driver).to receive(:find_elements).and_return([frame])
660
+
661
+ dom = Percy.get_serialized_dom(driver, {}, percy_dom_script: 'percy_dom_script')
662
+ expect(dom).to_not have_key('corsIframes')
663
+ end
664
+
665
+ it 'does not process cross-origin iframes when percy_dom_script is nil' do
666
+ allow(driver).to receive(:execute_script).and_return({'html' => '<html/>'})
667
+ allow(driver).to receive(:current_url).and_return('http://main.example.com/')
668
+ expect(driver).to_not receive(:find_elements)
669
+
670
+ dom = Percy.get_serialized_dom(driver, {}, percy_dom_script: nil)
671
+ expect(dom).to_not have_key('corsIframes')
672
+ end
673
+
674
+ it 'treats same host with different scheme as cross-origin' do
675
+ frame = double('frame')
676
+ allow(frame).to receive(:attribute).with('src').and_return('https://main.example.com/widget')
677
+ allow(frame).to receive(:attribute).with('data-percy-element-id').and_return('percy-id-1')
678
+
679
+ call_count = 0
680
+ allow(driver).to receive(:execute_script) do
681
+ call_count += 1
682
+ if call_count == 1
683
+ {'html' => '<html/>'}
684
+ else
685
+ call_count == 2 ? nil : {'html' => '<frame/>'}
686
+ end
687
+ end
688
+ allow(driver).to receive(:current_url).and_return('http://main.example.com/')
689
+ allow(driver).to receive(:find_elements).and_return([frame])
690
+
691
+ dom = Percy.get_serialized_dom(driver, {}, percy_dom_script: 'script')
692
+ expect(dom).to have_key('corsIframes')
693
+ end
694
+
695
+ it 'treats same host with different port as cross-origin' do
696
+ frame = double('frame')
697
+ allow(frame).to receive(:attribute).with('src').and_return('http://main.example.com:4000/widget')
698
+ allow(frame).to receive(:attribute).with('data-percy-element-id').and_return('percy-id-port')
699
+
700
+ call_count = 0
701
+ allow(driver).to receive(:execute_script) do
702
+ call_count += 1
703
+ if call_count == 1
704
+ {'html' => '<html/>'}
705
+ else
706
+ call_count == 2 ? nil : {'html' => '<frame/>'}
707
+ end
708
+ end
709
+ allow(driver).to receive(:current_url).and_return('http://main.example.com:3000/')
710
+ allow(driver).to receive(:find_elements).and_return([frame])
711
+
712
+ dom = Percy.get_serialized_dom(driver, {}, percy_dom_script: 'script')
713
+ expect(dom).to have_key('corsIframes')
714
+ end
715
+
716
+ it 'always attaches cookies to the snapshot' do
717
+ cookies_data = [{'name' => 'session', 'value' => 'abc'}]
718
+ allow(manage).to receive(:all_cookies).and_return(cookies_data)
719
+ allow(driver).to receive(:execute_script).and_return({'html' => '<html/>'})
720
+ allow(driver).to receive(:current_url).and_return('http://main.example.com/')
721
+ allow(driver).to receive(:find_elements).and_return([])
722
+
723
+ dom = Percy.get_serialized_dom(driver, {})
724
+ expect(dom['cookies']).to eq(cookies_data)
725
+ end
726
+
727
+ it 'skips same-origin frame and processes only cross-origin frame' do
728
+ same_frame = double('same_frame')
729
+ allow(same_frame).to receive(:attribute).with('src').and_return('http://main.example.com/inner')
730
+
731
+ cross_frame = double('cross_frame')
732
+ allow(cross_frame).to receive(:attribute).with('src').and_return('https://other.example.com/page')
733
+ allow(cross_frame).to receive(:attribute).with('data-percy-element-id').and_return('cid-x')
734
+
735
+ call_count = 0
736
+ allow(driver).to receive(:execute_script) do
737
+ call_count += 1
738
+ if call_count == 1
739
+ {'html' => '<main/>'}
740
+ else
741
+ call_count == 2 ? nil : {'html' => '<cross/>'}
742
+ end
743
+ end
744
+ allow(driver).to receive(:current_url).and_return('http://main.example.com/')
745
+ allow(driver).to receive(:find_elements).and_return([same_frame, cross_frame])
746
+
747
+ dom = Percy.get_serialized_dom(driver, {}, percy_dom_script: 'script')
748
+ expect(dom['corsIframes'].length).to eq(1)
749
+ expect(dom['corsIframes'][0]['frameUrl']).to eq('https://other.example.com/page')
750
+ end
751
+ end
752
+
753
+ describe '.change_window_dimension_and_wait' do
754
+ let(:driver) { double('driver') }
755
+ let(:manage) { double('manage') }
756
+ let(:window) { double('window') }
757
+ let(:wait) { instance_double(Selenium::WebDriver::Wait) }
758
+ let(:caps_firefox) { double('capabilities', browser_name: 'firefox') }
759
+
760
+ before(:each) do
761
+ allow(driver).to receive(:manage).and_return(manage)
762
+ allow(manage).to receive(:window).and_return(window)
763
+ allow(window).to receive(:resize_to)
764
+ allow(driver).to receive(:capabilities).and_return(caps_firefox)
765
+ allow(driver).to receive(:respond_to?).with(:driver).and_return(false)
766
+ allow(driver).to receive(:respond_to?).with(:execute_cdp).and_return(false)
767
+ allow(driver).to receive(:execute_script) do |script|
768
+ {'w' => 1024, 'h' => 768} if script.include?('innerWidth')
769
+ end
770
+ allow(Selenium::WebDriver::Wait).to receive(:new).and_return(wait)
771
+ allow(wait).to receive(:until)
772
+ allow(Percy).to receive(:log)
773
+ end
774
+
775
+ it 'resizes the window using resize_to for non-chrome browsers' do
776
+ expect(window).to receive(:resize_to).with(768, 1024)
777
+ Percy.change_window_dimension_and_wait(driver, 768, 1024, 1)
778
+ end
779
+
780
+ it 'dispatches a resize event after resizing for non-chrome browsers' do
781
+ expect(driver).to receive(:execute_script)
782
+ .with("window.dispatchEvent(new Event('resize'));")
783
+ Percy.change_window_dimension_and_wait(driver, 768, 1024, 1)
784
+ end
785
+
786
+ it 'uses execute_cdp for chrome when execute_cdp is available' do
787
+ chrome_caps = double('capabilities', browser_name: 'chrome')
788
+ allow(driver).to receive(:capabilities).and_return(chrome_caps)
789
+ allow(driver).to receive(:respond_to?).with(:execute_cdp).and_return(true)
790
+ expect(driver).to receive(:execute_cdp).with(
791
+ 'Emulation.setDeviceMetricsOverride',
792
+ {height: 812, width: 375, deviceScaleFactor: 1, mobile: false},
793
+ )
794
+ Percy.change_window_dimension_and_wait(driver, 375, 812, 1)
795
+ end
796
+
797
+ it 'does not call resize_to when cdp succeeds for chrome' do
798
+ chrome_caps = double('capabilities', browser_name: 'chrome')
799
+ allow(driver).to receive(:capabilities).and_return(chrome_caps)
800
+ allow(driver).to receive(:respond_to?).with(:execute_cdp).and_return(true)
801
+ allow(driver).to receive(:execute_cdp)
802
+ expect(window).to_not receive(:resize_to)
803
+ Percy.change_window_dimension_and_wait(driver, 375, 812, 1)
804
+ end
805
+
806
+ it 'falls back to resize_to when execute_cdp raises' do
807
+ chrome_caps = double('capabilities', browser_name: 'chrome')
808
+ allow(driver).to receive(:capabilities).and_return(chrome_caps)
809
+ allow(driver).to receive(:respond_to?).with(:execute_cdp).and_return(true)
810
+ allow(driver).to receive(:execute_cdp).and_raise(StandardError, 'cdp error')
811
+ expect(window).to receive(:resize_to).with(375, 812)
812
+ Percy.change_window_dimension_and_wait(driver, 375, 812, 1)
813
+ end
814
+
815
+ it 'dispatches a resize event in the cdp fallback path' do
816
+ chrome_caps = double('capabilities', browser_name: 'chrome')
817
+ allow(driver).to receive(:capabilities).and_return(chrome_caps)
818
+ allow(driver).to receive(:respond_to?).with(:execute_cdp).and_return(true)
819
+ allow(driver).to receive(:execute_cdp).and_raise(StandardError, 'cdp error')
820
+ expect(driver).to receive(:execute_script)
821
+ .with("window.dispatchEvent(new Event('resize'));")
822
+ Percy.change_window_dimension_and_wait(driver, 375, 812, 1)
823
+ end
824
+ end
825
+
826
+ describe '.capture_responsive_dom' do
827
+ let(:driver) { double('driver') }
828
+ let(:manage) { double('manage') }
829
+ let(:window) { double('window') }
830
+ let(:size) { double('size', width: 1280, height: 900) }
831
+ let(:navigate) { double('navigate') }
832
+
833
+ before(:each) do
834
+ allow(driver).to receive(:manage).and_return(manage)
835
+ allow(manage).to receive(:window).and_return(window)
836
+ allow(window).to receive(:size).and_return(size)
837
+ allow(driver).to receive(:respond_to?).with(:driver).and_return(false)
838
+ allow(driver).to receive(:execute_script).and_return(nil)
839
+ allow(Percy).to receive(:get_serialized_dom).and_return({'html' => '<html/>'})
840
+ allow(Percy).to receive(:change_window_dimension_and_wait)
841
+ allow(Percy).to receive(:log)
842
+ end
843
+
844
+ # -----------------------------------------------------------------------
845
+ # Resize behavior
846
+ # -----------------------------------------------------------------------
847
+
848
+ it 'calls change_window_dimension_and_wait for each distinct width' do
849
+ allow(Percy).to receive(:get_responsive_widths).and_return(
850
+ [{'width' => 375}, {'width' => 768}, {'width' => 1280}],
851
+ )
852
+ # Each of the 3 widths differs from the previous + final restore = 4 calls
853
+ expect(Percy).to receive(:change_window_dimension_and_wait).exactly(4).times
854
+ Percy.capture_responsive_dom(driver, {})
855
+ end
856
+
857
+ it 'skips resize when consecutive entries have the same width and height' do
858
+ allow(Percy).to receive(:get_responsive_widths).and_return(
859
+ [{'width' => 375}, {'width' => 375}],
860
+ )
861
+ # 375 (first, differs from 1280) + final restore = 2; second 375 skipped
862
+ expect(Percy).to receive(:change_window_dimension_and_wait).twice
863
+ Percy.capture_responsive_dom(driver, {})
864
+ end
865
+
866
+ it 'passes the requested width and window height to change_window_dimension_and_wait' do
867
+ allow(Percy).to receive(:get_responsive_widths).and_return([{'width' => 390}])
868
+ expect(Percy).to receive(:change_window_dimension_and_wait)
869
+ .with(driver, 390, 900, anything)
870
+ Percy.capture_responsive_dom(driver, {})
871
+ end
872
+
873
+ it 'uses per-entry height from widths list over the default target_height' do
874
+ allow(Percy).to receive(:get_responsive_widths).and_return(
875
+ [{'width' => 390, 'height' => 844}],
876
+ )
877
+ expect(Percy).to receive(:change_window_dimension_and_wait)
878
+ .with(driver, 390, 844, anything)
879
+ Percy.capture_responsive_dom(driver, {})
880
+ end
881
+
882
+ it 'restores original window dimensions after processing all widths' do
883
+ allow(Percy).to receive(:get_responsive_widths).and_return([{'width' => 375}])
884
+ expect(Percy).to receive(:change_window_dimension_and_wait)
885
+ .with(driver, 1280, 900, anything)
886
+ Percy.capture_responsive_dom(driver, {})
887
+ end
888
+
889
+ # -----------------------------------------------------------------------
890
+ # Reload-page flag
891
+ # -----------------------------------------------------------------------
892
+
893
+ context 'when PERCY_RESPONSIVE_CAPTURE_RELOAD_PAGE is true' do
894
+ before(:each) do
895
+ allow(Percy).to receive(:responsive_capture_reload_page?).and_return(true)
896
+ allow(Percy).to receive(:fetch_percy_dom).and_return('percy_dom_script')
897
+ allow(Percy).to receive(:get_responsive_widths).and_return(
898
+ [{'width' => 375}, {'width' => 768}],
899
+ )
900
+ allow(driver).to receive(:navigate).and_return(navigate)
901
+ allow(navigate).to receive(:refresh)
902
+ end
903
+
904
+ it 'calls driver.navigate.refresh once per width' do
905
+ expect(navigate).to receive(:refresh).twice
906
+ Percy.capture_responsive_dom(driver, {})
907
+ end
908
+
909
+ it 're-fetches percy_dom after each reload' do
910
+ expect(Percy).to receive(:fetch_percy_dom).twice
911
+ Percy.capture_responsive_dom(driver, {})
912
+ end
913
+
914
+ it 'falls back to driver.driver.browser.navigate.refresh when direct refresh raises' do
915
+ allow(Percy).to receive(:get_responsive_widths).and_return([{'width' => 375}])
916
+ allow(navigate).to receive(:refresh).and_raise(StandardError, 'direct refresh failed')
917
+
918
+ inner_nav = double('inner_navigate')
919
+ inner_browser = double('inner_browser')
920
+ inner_drv = double('inner_driver', browser: inner_browser)
921
+ allow(driver).to receive(:driver).and_return(inner_drv)
922
+ allow(inner_browser).to receive(:navigate).and_return(inner_nav)
923
+ expect(inner_nav).to receive(:refresh).once
924
+ Percy.capture_responsive_dom(driver, {})
925
+ end
926
+ end
927
+
928
+ # -----------------------------------------------------------------------
929
+ # minHeight / PERCY_RESPONSIVE_CAPTURE_MIN_HEIGHT flag
930
+ # -----------------------------------------------------------------------
931
+
932
+ context 'when PERCY_RESPONSIVE_CAPTURE_MIN_HEIGHT is true' do
933
+ before(:each) do
934
+ allow(Percy).to receive(:responsive_capture_min_height?).and_return(true)
935
+ allow(Percy).to receive(:get_responsive_widths).and_return([{'width' => 390}])
936
+ end
937
+
938
+ it 'uses the minHeight option as the target height' do
939
+ expect(Percy).to receive(:change_window_dimension_and_wait)
940
+ .with(driver, 390, 800, anything)
941
+ Percy.capture_responsive_dom(driver, {minHeight: 800})
942
+ end
943
+
944
+ it 'uses minHeight from cli_config when not provided in options' do
945
+ begin
946
+ Percy.instance_variable_set(:@cli_config, {'snapshot' => {'minHeight' => 700}})
947
+ expect(Percy).to receive(:change_window_dimension_and_wait)
948
+ .with(driver, 390, 700, anything)
949
+ Percy.capture_responsive_dom(driver, {})
950
+ ensure
951
+ Percy.instance_variable_set(:@cli_config, nil)
952
+ end
953
+ end
954
+
955
+ it 'uses the current window height unchanged when minHeight is not set' do
956
+ expect(Percy).to receive(:change_window_dimension_and_wait)
957
+ .with(driver, 390, 900, anything)
958
+ Percy.capture_responsive_dom(driver, {})
959
+ end
960
+ end
961
+ end
962
+ end
963
+
964
+ RSpec.describe Percy do
965
+ before(:each) do
966
+ Percy._clear_cache!
967
+ end
968
+
969
+ describe '.responsive_snapshot_capture?' do
970
+ it 'returns false when deferUploads is enabled in cli_config' do
971
+ begin
972
+ Percy.instance_variable_set(:@cli_config, {'percy' => {'deferUploads' => true}})
973
+ result = Percy.responsive_snapshot_capture?({responsive_snapshot_capture: true})
974
+ expect(result).to be false
975
+ ensure
976
+ Percy.instance_variable_set(:@cli_config, nil)
977
+ end
978
+ end
979
+
980
+ it 'returns true when responsive_snapshot_capture option is set' do
981
+ result = Percy.responsive_snapshot_capture?({responsive_snapshot_capture: true})
982
+ expect(result).to be true
983
+ end
984
+
985
+ it 'returns true when responsiveSnapshotCapture option is set' do
986
+ result = Percy.responsive_snapshot_capture?({responsiveSnapshotCapture: true})
987
+ expect(result).to be true
988
+ end
989
+
990
+ it 'returns true when responsiveSnapshotCapture is set in cli_config' do
991
+ begin
992
+ Percy.instance_variable_set(
993
+ :@cli_config, {'snapshot' => {'responsiveSnapshotCapture' => true}},
994
+ )
995
+ result = Percy.responsive_snapshot_capture?({})
996
+ expect(result).to be true
997
+ ensure
998
+ Percy.instance_variable_set(:@cli_config, nil)
999
+ end
1000
+ end
1001
+
1002
+ it 'returns nil when no responsive capture option is set' do
1003
+ result = Percy.responsive_snapshot_capture?({})
1004
+ expect(result).to be_falsey
1005
+ end
1006
+ end
1007
+ end
1008
+
315
1009
  RSpec.describe Percy, type: :feature do
316
1010
  before(:each) do
317
1011
  WebMock.reset!
@@ -329,7 +1023,8 @@ RSpec.describe Percy, type: :feature do
329
1023
  healthcheck = requests[0]
330
1024
  expect(healthcheck['url']).to eq('/percy/healthcheck')
331
1025
 
332
- snap = requests[2]['body']
1026
+ snap_request = requests.find { |r| r['url'] == '/percy/snapshot' }
1027
+ snap = snap_request['body']
333
1028
  expect(snap['name']).to eq('Name')
334
1029
  expect(snap['url']).to eq('http://127.0.0.1:3003/index.html')
335
1030
  expect(snap['client_info']).to include('percy-selenium-ruby')
@@ -338,4 +1033,297 @@ RSpec.describe Percy, type: :feature do
338
1033
  end
339
1034
  end
340
1035
  end
1036
+
1037
+ RSpec.describe Percy do
1038
+ describe '.percy_screenshot' do
1039
+ let(:driver) { double('driver') }
1040
+ let(:metadata) do
1041
+ instance_double(
1042
+ DriverMetaData,
1043
+ session_id: 'sess-abc-123',
1044
+ command_executor_url: 'http://hub.browserstack.com/wd/hub',
1045
+ capabilities: {'browserName' => 'chrome', 'version' => '114'},
1046
+ )
1047
+ end
1048
+
1049
+ let(:automate_url) { "#{Percy::PERCY_SERVER_ADDRESS}/percy/automateScreenshot" }
1050
+
1051
+ before(:each) do
1052
+ WebMock.disable_net_connect!
1053
+ stub_request(:post, 'http://localhost:5338/percy/log').to_raise(StandardError)
1054
+ Percy._clear_cache!
1055
+ allow(Percy).to receive(:get_driver_metadata).with(driver).and_return(metadata)
1056
+ end
1057
+
1058
+ def stub_automate_healthcheck
1059
+ stub_request(:get, "#{Percy::PERCY_SERVER_ADDRESS}/percy/healthcheck")
1060
+ .to_return(
1061
+ status: 200,
1062
+ body: '{"success":true,"type":"automate"}',
1063
+ headers: {'x-percy-core-version': '1.0.0'},
1064
+ )
1065
+ end
1066
+
1067
+ def stub_web_healthcheck
1068
+ stub_request(:get, "#{Percy::PERCY_SERVER_ADDRESS}/percy/healthcheck")
1069
+ .to_return(
1070
+ status: 200,
1071
+ body: '{"success":true,"type":"web"}',
1072
+ headers: {'x-percy-core-version': '1.0.0'},
1073
+ )
1074
+ end
1075
+
1076
+ # -------------------------------------------------------------------------
1077
+ # percy_enabled? gating
1078
+ # -------------------------------------------------------------------------
1079
+
1080
+ it 'returns nil without posting when percy is not running' do
1081
+ stub_request(:get, "#{Percy::PERCY_SERVER_ADDRESS}/percy/healthcheck")
1082
+ .to_return(status: 500, body: '')
1083
+
1084
+ result = nil
1085
+ expect { result = Percy.percy_screenshot(driver, 'DisabledShot') }
1086
+ .to output(/Percy is not running/).to_stdout
1087
+ expect(result).to be_nil
1088
+ expect(WebMock).to_not have_requested(:post, /automateScreenshot/)
1089
+ end
1090
+
1091
+ it 'returns nil without posting when healthcheck raises a connection error' do
1092
+ stub_request(:get, "#{Percy::PERCY_SERVER_ADDRESS}/percy/healthcheck")
1093
+ .to_raise(StandardError, 'connection refused')
1094
+
1095
+ result = nil
1096
+ expect { result = Percy.percy_screenshot(driver, 'ConnErrShot') }
1097
+ .to output(/Percy is not running/).to_stdout
1098
+ expect(result).to be_nil
1099
+ end
1100
+
1101
+ # -------------------------------------------------------------------------
1102
+ # Session-type enforcement
1103
+ # -------------------------------------------------------------------------
1104
+
1105
+ it 'raises with a descriptive message when session type is not automate' do
1106
+ stub_web_healthcheck
1107
+
1108
+ expect { Percy.percy_screenshot(driver, 'WebShot') }
1109
+ .to raise_error(
1110
+ StandardError,
1111
+ /Invalid function call - percy_screenshot\(\)/,
1112
+ )
1113
+ end
1114
+
1115
+ it 'error message for wrong session type includes guidance to use percy_snapshot' do
1116
+ stub_web_healthcheck
1117
+
1118
+ expect { Percy.percy_screenshot(driver, 'WebShot') }
1119
+ .to raise_error(StandardError, /percy_snapshot\(\)/)
1120
+ end
1121
+
1122
+ it 'does not raise when session type is automate' do
1123
+ stub_automate_healthcheck
1124
+ stub_request(:post, "#{Percy::PERCY_SERVER_ADDRESS}/percy/automateScreenshot")
1125
+ .to_return(status: 200, body: '{"success":true}')
1126
+
1127
+ expect { Percy.percy_screenshot(driver, 'AutoShot') }.to_not raise_error
1128
+ end
1129
+
1130
+ # -------------------------------------------------------------------------
1131
+ # Request payload construction
1132
+ # -------------------------------------------------------------------------
1133
+
1134
+ it 'posts to percy/automateScreenshot with correct top-level fields' do
1135
+ stub_automate_healthcheck
1136
+ stub_request(:post, "#{Percy::PERCY_SERVER_ADDRESS}/percy/automateScreenshot")
1137
+ .to_return(status: 200, body: '{"success":true,"data":"snap-result"}')
1138
+
1139
+ Percy.percy_screenshot(driver, 'PayloadShot')
1140
+
1141
+ expect(WebMock).to have_requested(:post, automate_url)
1142
+ .with { |req|
1143
+ body = JSON.parse(req.body)
1144
+ body['snapshotName'] == 'PayloadShot' &&
1145
+ body['sessionId'] == 'sess-abc-123' &&
1146
+ body['commandExecutorUrl'] == 'http://hub.browserstack.com/wd/hub' &&
1147
+ body['capabilities'] == {'browserName' => 'chrome', 'version' => '114'} &&
1148
+ body['client_info'] == "percy-selenium-ruby/#{Percy::VERSION}" &&
1149
+ body['environment_info'] ==
1150
+ "selenium/#{Selenium::WebDriver::VERSION} ruby/#{RUBY_VERSION}"
1151
+ }.once
1152
+ end
1153
+
1154
+ # -------------------------------------------------------------------------
1155
+ # Response handling - success
1156
+ # -------------------------------------------------------------------------
1157
+
1158
+ it 'returns body["data"] when the response indicates success' do
1159
+ stub_automate_healthcheck
1160
+ stub_request(:post, "#{Percy::PERCY_SERVER_ADDRESS}/percy/automateScreenshot")
1161
+ .to_return(status: 200, body: '{"success":true,"data":"my-screenshot-data"}')
1162
+
1163
+ result = Percy.percy_screenshot(driver, 'SuccessShot')
1164
+ expect(result).to eq('my-screenshot-data')
1165
+ end
1166
+
1167
+ it 'returns nil when data key is absent in a successful response' do
1168
+ stub_automate_healthcheck
1169
+ stub_request(:post, "#{Percy::PERCY_SERVER_ADDRESS}/percy/automateScreenshot")
1170
+ .to_return(status: 200, body: '{"success":true}')
1171
+
1172
+ result = Percy.percy_screenshot(driver, 'NoDataShot')
1173
+ expect(result).to be_nil
1174
+ end
1175
+
1176
+ # -------------------------------------------------------------------------
1177
+ # Response handling - errors
1178
+ # -------------------------------------------------------------------------
1179
+
1180
+ it 'logs and returns nil when response success is false' do
1181
+ stub_automate_healthcheck
1182
+ stub_request(:post, "#{Percy::PERCY_SERVER_ADDRESS}/percy/automateScreenshot")
1183
+ .to_return(status: 200, body: '{"success":false,"error":"upstream failure"}')
1184
+
1185
+ result = nil
1186
+ expect { result = Percy.percy_screenshot(driver, 'FailShot') }
1187
+ .to output("#{Percy::LABEL} Could not take Screenshot 'FailShot'\n").to_stdout
1188
+ expect(result).to be_nil
1189
+ end
1190
+
1191
+ it 'logs and returns nil when the HTTP request returns a non-success status' do
1192
+ stub_automate_healthcheck
1193
+ stub_request(:post, "#{Percy::PERCY_SERVER_ADDRESS}/percy/automateScreenshot")
1194
+ .to_return(status: 500, body: 'Internal Server Error')
1195
+
1196
+ result = nil
1197
+ expect { result = Percy.percy_screenshot(driver, 'HttpErrShot') }
1198
+ .to output("#{Percy::LABEL} Could not take Screenshot 'HttpErrShot'\n").to_stdout
1199
+ expect(result).to be_nil
1200
+ end
1201
+
1202
+ it 'logs and returns nil when the automateScreenshot endpoint raises' do
1203
+ stub_automate_healthcheck
1204
+ stub_request(:post, "#{Percy::PERCY_SERVER_ADDRESS}/percy/automateScreenshot")
1205
+ .to_raise(StandardError, 'network timeout')
1206
+
1207
+ result = nil
1208
+ expect { result = Percy.percy_screenshot(driver, 'RaiseShot') }
1209
+ .to output("#{Percy::LABEL} Could not take Screenshot 'RaiseShot'\n").to_stdout
1210
+ expect(result).to be_nil
1211
+ end
1212
+
1213
+ # -------------------------------------------------------------------------
1214
+ # Option key translation
1215
+ # -------------------------------------------------------------------------
1216
+
1217
+ it 'translates camelCase ignoreRegionSeleniumElements to snake_case and extracts ids' do
1218
+ stub_automate_healthcheck
1219
+ elem1 = double('element1', id: 'elem-id-1')
1220
+ elem2 = double('element2', id: 'elem-id-2')
1221
+
1222
+ stub_request(:post, "#{Percy::PERCY_SERVER_ADDRESS}/percy/automateScreenshot")
1223
+ .to_return(status: 200, body: '{"success":true}')
1224
+
1225
+ Percy.percy_screenshot(driver, 'IgnoreShot', ignoreRegionSeleniumElements: [elem1, elem2])
1226
+
1227
+ expect(WebMock).to have_requested(:post, automate_url)
1228
+ .with { |req|
1229
+ opts = JSON.parse(req.body)['options']
1230
+ opts['ignore_region_elements'] == %w[elem-id-1 elem-id-2] &&
1231
+ !opts.key?('ignoreRegionSeleniumElements') &&
1232
+ !opts.key?('ignore_region_selenium_elements')
1233
+ }.once
1234
+ end
1235
+
1236
+ it 'translates camelCase considerRegionSeleniumElements to snake_case and extracts ids' do
1237
+ stub_automate_healthcheck
1238
+ elem = double('element', id: 'consider-id-1')
1239
+
1240
+ stub_request(:post, "#{Percy::PERCY_SERVER_ADDRESS}/percy/automateScreenshot")
1241
+ .to_return(status: 200, body: '{"success":true}')
1242
+
1243
+ Percy.percy_screenshot(driver, 'ConsiderShot', considerRegionSeleniumElements: [elem])
1244
+
1245
+ expect(WebMock).to have_requested(:post, automate_url)
1246
+ .with { |req|
1247
+ opts = JSON.parse(req.body)['options']
1248
+ opts['consider_region_elements'] == ['consider-id-1'] &&
1249
+ !opts.key?('considerRegionSeleniumElements') &&
1250
+ !opts.key?('consider_region_selenium_elements')
1251
+ }.once
1252
+ end
1253
+
1254
+ it 'also accepts already-snake_case ignore_region_selenium_elements' do
1255
+ stub_automate_healthcheck
1256
+ elem = double('element', id: 'snake-id-1')
1257
+
1258
+ stub_request(:post, "#{Percy::PERCY_SERVER_ADDRESS}/percy/automateScreenshot")
1259
+ .to_return(status: 200, body: '{"success":true}')
1260
+
1261
+ Percy.percy_screenshot(driver, 'SnakeShot', ignore_region_selenium_elements: [elem])
1262
+
1263
+ expect(WebMock).to have_requested(:post, automate_url)
1264
+ .with { |req|
1265
+ opts = JSON.parse(req.body)['options']
1266
+ opts['ignore_region_elements'] == ['snake-id-1']
1267
+ }.once
1268
+ end
1269
+
1270
+ # -------------------------------------------------------------------------
1271
+ # Element ID extraction
1272
+ # -------------------------------------------------------------------------
1273
+
1274
+ it 'uses empty arrays for element ids when no element options are supplied' do
1275
+ stub_automate_healthcheck
1276
+ stub_request(:post, "#{Percy::PERCY_SERVER_ADDRESS}/percy/automateScreenshot")
1277
+ .to_return(status: 200, body: '{"success":true}')
1278
+
1279
+ Percy.percy_screenshot(driver, 'NoElemsShot')
1280
+
1281
+ expect(WebMock).to have_requested(:post, automate_url)
1282
+ .with { |req|
1283
+ opts = JSON.parse(req.body)['options']
1284
+ opts['ignore_region_elements'] == [] &&
1285
+ opts['consider_region_elements'] == []
1286
+ }.once
1287
+ end
1288
+
1289
+ it 'extracts the id from each selenium element object' do
1290
+ stub_automate_healthcheck
1291
+ elements = [
1292
+ double('el_a', id: 'id-a'),
1293
+ double('el_b', id: 'id-b'),
1294
+ double('el_c', id: 'id-c'),
1295
+ ]
1296
+
1297
+ stub_request(:post, "#{Percy::PERCY_SERVER_ADDRESS}/percy/automateScreenshot")
1298
+ .to_return(status: 200, body: '{"success":true}')
1299
+
1300
+ Percy.percy_screenshot(driver, 'MultiElemShot',
1301
+ ignore_region_selenium_elements: elements,)
1302
+
1303
+ expect(WebMock).to have_requested(:post, automate_url)
1304
+ .with { |req|
1305
+ opts = JSON.parse(req.body)['options']
1306
+ opts['ignore_region_elements'] == %w[id-a id-b id-c]
1307
+ }.once
1308
+ end
1309
+
1310
+ # -------------------------------------------------------------------------
1311
+ # Passthrough of additional options
1312
+ # -------------------------------------------------------------------------
1313
+
1314
+ it 'passes unknown options through to the request payload unchanged' do
1315
+ stub_automate_healthcheck
1316
+ stub_request(:post, "#{Percy::PERCY_SERVER_ADDRESS}/percy/automateScreenshot")
1317
+ .to_return(status: 200, body: '{"success":true}')
1318
+
1319
+ Percy.percy_screenshot(driver, 'ExtraOptsShot', sync: true, fullPage: true)
1320
+
1321
+ expect(WebMock).to have_requested(:post, automate_url)
1322
+ .with { |req|
1323
+ opts = JSON.parse(req.body)['options']
1324
+ opts['sync'] == true && opts['fullPage'] == true
1325
+ }.once
1326
+ end
1327
+ end
1328
+ end
341
1329
  # rubocop:enable RSpec/MultipleDescribes