percy-selenium 1.1.3.pre.beta.0 → 1.1.4.pre.beta.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 +4 -4
- data/.github/workflows/Semgrep.yml +1 -2
- data/.github/workflows/test.yml +14 -4
- data/.gitignore +1 -0
- data/.npmrc +6 -0
- data/Gemfile +7 -2
- data/lib/percy.rb +32 -3
- data/lib/version.rb +1 -1
- data/spec/lib/percy/percy_spec.rb +376 -21
- data/spec/spec_helper.rb +29 -2
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 964b34504d72f46df90ac38f0c122efb350e6a46673899855cbf668523d93349
|
|
4
|
+
data.tar.gz: 7f2cbab4d9f0228ce5b29d24601a04dea8f11dfb2a359dea52eddfe741463c85
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e4914dc15a922d03df46fa24a68576d89e784314d9e40c55b1dd3f3e2b32a746a94eca1e55c7f54a204046a3a98c13954d3e29200ebfbae8fe8f49c7e9d5fc74
|
|
7
|
+
data.tar.gz: b562acce5038bbaf57e50767d6c9a1cf10e19beabe46f97686576e427071a37492ea0b90b9936dec6ad7c9e5ee9c545283ae766744d79290984c82c17cb664d5
|
|
@@ -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
|
|
data/.github/workflows/test.yml
CHANGED
|
@@ -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:
|
|
@@ -50,16 +51,25 @@ jobs:
|
|
|
50
51
|
- run: yarn
|
|
51
52
|
- name: Set up @percy/cli from git
|
|
52
53
|
if: ${{ github.event_name == 'workflow_dispatch' }}
|
|
54
|
+
env:
|
|
55
|
+
BRANCH: ${{ github.event.inputs.branch }}
|
|
53
56
|
run: |
|
|
54
57
|
cd /tmp
|
|
55
|
-
git clone --branch $
|
|
58
|
+
git clone --branch "$BRANCH" --depth 1 https://github.com/percy/cli
|
|
56
59
|
cd cli
|
|
57
60
|
PERCY_PACKAGES=`find packages -mindepth 1 -maxdepth 1 -type d | sed -e 's/packages/@percy/g' | tr '\n' ' '`
|
|
58
61
|
git log -1
|
|
59
62
|
yarn
|
|
60
63
|
yarn build
|
|
61
64
|
yarn global:link
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
+
# Expose the freshly built `percy` on PATH. yarn link symlinks the
|
|
66
|
+
# package but not its bin, so `npx percy` would miss it and download
|
|
67
|
+
# the unrelated public `percy`. Link is best-effort; PATH is what
|
|
68
|
+
# makes percy resolvable.
|
|
69
|
+
echo "$(yarn global bin)" >> "$GITHUB_PATH"
|
|
70
|
+
export PATH="$(yarn global bin):$PATH"
|
|
71
|
+
cd ${{ github.workspace }}
|
|
72
|
+
yarn remove @percy/cli >/dev/null 2>&1 || true
|
|
73
|
+
yarn link `echo $PERCY_PACKAGES` >/dev/null 2>&1 || true
|
|
74
|
+
percy --version
|
|
65
75
|
- run: npx percy exec --testing -- bundle exec rspec
|
data/.gitignore
CHANGED
data/.npmrc
ADDED
data/Gemfile
CHANGED
|
@@ -7,8 +7,13 @@ gem "guard-rspec", require: false
|
|
|
7
7
|
|
|
8
8
|
group :test, :development do
|
|
9
9
|
gem "webmock"
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
# Capybara 3.36 (the newest Capybara that supports Ruby 2.6, which CI still
|
|
11
|
+
# targets) uses the Puma 5 events API; Puma 6 removed Puma::Events.strings,
|
|
12
|
+
# which broke the Capybara server boot. Pin to Puma 5 for compatibility.
|
|
13
|
+
gem "puma", '~> 5'
|
|
14
|
+
# Puma 5's rack handler requires `rack/handler`, which Rack 3 removed (it
|
|
15
|
+
# moved to the separate `rackup` gem). Pin Rack 2 so Capybara can boot Puma.
|
|
16
|
+
gem "rack", '~> 2.2'
|
|
12
17
|
gem "pry"
|
|
13
18
|
gem "simplecov", require: false
|
|
14
19
|
end
|
data/lib/percy.rb
CHANGED
|
@@ -66,6 +66,25 @@ module Percy
|
|
|
66
66
|
region
|
|
67
67
|
end
|
|
68
68
|
|
|
69
|
+
# Recursively convert all Hash keys (at every nesting level) to symbols so
|
|
70
|
+
# config (string keys from JSON) and per-call options (symbol keys) merge on
|
|
71
|
+
# consistent keys. Arrays are walked; scalars are returned as-is.
|
|
72
|
+
def self.deep_symbolize(obj)
|
|
73
|
+
case obj
|
|
74
|
+
when Hash then obj.each_with_object({}) { |(k, v), h| h[k.to_sym] = deep_symbolize(v) }
|
|
75
|
+
when Array then obj.map { |e| deep_symbolize(e) }
|
|
76
|
+
else obj
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Deep-merge `override` onto `base`: nested Hashes merge recursively, while
|
|
81
|
+
# arrays and scalars from `override` replace those in `base`.
|
|
82
|
+
def self.deep_merge_options(base, override)
|
|
83
|
+
base.merge(override) do |_key, old_val, new_val|
|
|
84
|
+
old_val.is_a?(Hash) && new_val.is_a?(Hash) ? deep_merge_options(old_val, new_val) : new_val
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
69
88
|
def self.snapshot(driver, name, options = {})
|
|
70
89
|
return unless percy_enabled?
|
|
71
90
|
|
|
@@ -79,10 +98,20 @@ module Percy
|
|
|
79
98
|
begin
|
|
80
99
|
percy_dom_script = fetch_percy_dom
|
|
81
100
|
driver.execute_script(percy_dom_script)
|
|
82
|
-
|
|
83
|
-
|
|
101
|
+
|
|
102
|
+
# Merge .percy.yml config options with snapshot options (snapshot options take priority)
|
|
103
|
+
config_options = @cli_config&.dig('snapshot') || {}
|
|
104
|
+
# Config keys are strings (JSON parse); per-call options use symbols, as
|
|
105
|
+
# do downstream consumers (responsive_snapshot_capture?, capture_responsive_dom).
|
|
106
|
+
# Deep-symbolize both sides so nested keys are consistent, then deep-merge
|
|
107
|
+
# so nested Hashes merge recursively (per-call wins at leaves; arrays/scalars
|
|
108
|
+
# replace) instead of a shallow top-level overwrite dropping config siblings.
|
|
109
|
+
merged_options = deep_merge_options(deep_symbolize(config_options), deep_symbolize(options))
|
|
110
|
+
|
|
111
|
+
dom_snapshot = if responsive_snapshot_capture?(merged_options)
|
|
112
|
+
capture_responsive_dom(driver, merged_options, percy_dom_script: percy_dom_script)
|
|
84
113
|
else
|
|
85
|
-
get_serialized_dom(driver,
|
|
114
|
+
get_serialized_dom(driver, merged_options, percy_dom_script: percy_dom_script)
|
|
86
115
|
end
|
|
87
116
|
|
|
88
117
|
# Strip `readiness` before POSTing -- SDK-local config that the CLI
|
data/lib/version.rb
CHANGED
|
@@ -210,6 +210,19 @@ RSpec.describe Percy, type: :feature do
|
|
|
210
210
|
expect(data).to eq(nil)
|
|
211
211
|
end
|
|
212
212
|
|
|
213
|
+
# Drives the full responsive `Percy.snapshot` path (capture_responsive_dom ->
|
|
214
|
+
# get_serialized_dom -> POST /percy/snapshot) and asserts on the real
|
|
215
|
+
# webmock-captured POST body.
|
|
216
|
+
#
|
|
217
|
+
# A faithful Selenium driver double is used instead of a live Firefox: a
|
|
218
|
+
# real headless Firefox is not deterministic for this flow on CI. The
|
|
219
|
+
# responsive capture resizes the window per width and then restores it in an
|
|
220
|
+
# `ensure`; headless Firefox / geckodriver intermittently crashes marionette
|
|
221
|
+
# on resize ("Failed to decode response from marionette" -> a dead session),
|
|
222
|
+
# whereupon the next WebDriver command raises InvalidSessionIdError. That
|
|
223
|
+
# error propagated out of capture_responsive_dom and was swallowed by
|
|
224
|
+
# Percy.snapshot's rescue, so no snapshot POST was ever sent and the captured
|
|
225
|
+
# body stayed nil. The double exercises the same code paths every time.
|
|
213
226
|
it 'sends multiple dom snapshots to the local server using selenium' do
|
|
214
227
|
stub_request(:get, "#{Percy::PERCY_SERVER_ADDRESS}/percy/healthcheck").to_return(
|
|
215
228
|
status: 200,
|
|
@@ -240,28 +253,60 @@ RSpec.describe Percy, type: :feature do
|
|
|
240
253
|
{status: 200, body: '{"success":true}', headers: {}}
|
|
241
254
|
end
|
|
242
255
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
256
|
+
# Faithful Selenium::WebDriver driver double covering every call the
|
|
257
|
+
# responsive snapshot path makes.
|
|
258
|
+
cookies = [{'name' => 'cookie-name', 'value' => 'cookie-value', 'path' => '/'}]
|
|
259
|
+
driver = double('driver')
|
|
260
|
+
manage = double('manage')
|
|
261
|
+
window = double('window')
|
|
262
|
+
window_size = double('window_size', width: 1280, height: 900)
|
|
263
|
+
capabilities = double('capabilities', browser_name: 'firefox')
|
|
264
|
+
|
|
265
|
+
allow(driver).to receive(:respond_to?).and_return(false)
|
|
266
|
+
allow(driver).to receive(:respond_to?).with(:driver).and_return(false)
|
|
267
|
+
allow(driver).to receive(:respond_to?).with(:execute_cdp).and_return(false)
|
|
268
|
+
allow(driver).to receive(:capabilities).and_return(capabilities)
|
|
269
|
+
allow(driver).to receive(:current_url).and_return('http://127.0.0.1:3003/index.html')
|
|
270
|
+
allow(driver).to receive(:find_elements).and_return([])
|
|
271
|
+
allow(driver).to receive(:manage).and_return(manage)
|
|
272
|
+
allow(manage).to receive(:window).and_return(window)
|
|
273
|
+
allow(manage).to receive(:all_cookies).and_return(cookies)
|
|
274
|
+
allow(window).to receive(:size).and_return(window_size)
|
|
275
|
+
allow(window).to receive(:resize_to)
|
|
276
|
+
# Resize wait: return immediately (no 1s timeout per width) and skip the
|
|
277
|
+
# innerWidth/innerHeight diagnostics read.
|
|
278
|
+
wait = instance_double(Selenium::WebDriver::Wait)
|
|
279
|
+
allow(Selenium::WebDriver::Wait).to receive(:new).and_return(wait)
|
|
280
|
+
allow(wait).to receive(:until)
|
|
281
|
+
# waitForReady gate: fake PercyDOM has no waitForReady, so the async script
|
|
282
|
+
# resolves with nil, exactly like a real browser would here.
|
|
283
|
+
allow(driver).to receive(:execute_async_script).and_return(nil)
|
|
284
|
+
# PercyDOM injection / waitForResize / dispatchEvent / resizeCount poll
|
|
285
|
+
# return nil; the innerWidth/innerHeight diagnostic read returns a size
|
|
286
|
+
# hash; the serialize call returns the serialized DOM (its `cookies` field
|
|
287
|
+
# is overwritten by the SDK from all_cookies afterward).
|
|
288
|
+
allow(driver).to receive(:execute_script) do |script|
|
|
289
|
+
if script.include?('PercyDOM.serialize')
|
|
290
|
+
{'html' => dom_string, 'cookies' => ''}
|
|
291
|
+
elsif script.include?('innerWidth')
|
|
292
|
+
{'w' => 1280, 'h' => 900}
|
|
263
293
|
end
|
|
264
294
|
end
|
|
295
|
+
|
|
296
|
+
data = Percy.snapshot(driver, 'Name', {responsive_snapshot_capture: true})
|
|
297
|
+
|
|
298
|
+
# Fail loudly with a meaningful message if the snapshot POST never fired
|
|
299
|
+
# (Percy.snapshot swallows StandardErrors), instead of a cryptic
|
|
300
|
+
# NoMethodError on nil when the body assertions run below.
|
|
301
|
+
expect(received_body).to_not(
|
|
302
|
+
be_nil, 'expected Percy.snapshot to POST /percy/snapshot, but no request was captured',
|
|
303
|
+
)
|
|
304
|
+
expect(received_body['name']).to eq('Name')
|
|
305
|
+
expect(received_body['url']).to eq('http://127.0.0.1:3003/index.html')
|
|
306
|
+
expect(received_body['dom_snapshot'].length).to eq(3)
|
|
307
|
+
expect(received_body['dom_snapshot'].map { |s| s['width'] }).to eq([390, 765, 1280])
|
|
308
|
+
expect(received_body['dom_snapshot'].first['cookies'].first['name']).to eq('cookie-name')
|
|
309
|
+
expect(data).to eq(nil)
|
|
265
310
|
end
|
|
266
311
|
|
|
267
312
|
it 'sends snapshots for sync' do
|
|
@@ -297,6 +342,85 @@ RSpec.describe Percy, type: :feature do
|
|
|
297
342
|
|
|
298
343
|
expect(data).to eq('sync_data')
|
|
299
344
|
end
|
|
345
|
+
|
|
346
|
+
it 'merges .percy.yml config with per-snapshot options (per-call wins)' do
|
|
347
|
+
# Healthcheck returns a config whose `snapshot` block carries a
|
|
348
|
+
# config-only key (enableJavaScript) and a percyCSS value that the
|
|
349
|
+
# per-snapshot call will override.
|
|
350
|
+
stub_request(:get, "#{Percy::PERCY_SERVER_ADDRESS}/percy/healthcheck")
|
|
351
|
+
.to_return(
|
|
352
|
+
status: 200,
|
|
353
|
+
body: {
|
|
354
|
+
success: true,
|
|
355
|
+
config: {'snapshot' => {'enableJavaScript' => true, 'percyCSS' => 'FROM_CONFIG'}},
|
|
356
|
+
}.to_json,
|
|
357
|
+
headers: {'x-percy-core-version': '1.0.0'},
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
stub_request(:get, "#{Percy::PERCY_SERVER_ADDRESS}/percy/dom.js")
|
|
361
|
+
.to_return(status: 200, body: fetch_script_string, headers: {})
|
|
362
|
+
|
|
363
|
+
stub_request(:post, 'http://localhost:5338/percy/snapshot')
|
|
364
|
+
.to_return(status: 200, body: '{"success":true}', headers: {})
|
|
365
|
+
|
|
366
|
+
# Capture the argument passed to PercyDOM.serialize so we can assert how
|
|
367
|
+
# config and per-call options were merged before serialization.
|
|
368
|
+
captured_serialize_call = nil
|
|
369
|
+
allow(page).to receive(:execute_script).and_wrap_original do |original, script, *args|
|
|
370
|
+
captured_serialize_call = script if script.to_s.include?('PercyDOM.serialize')
|
|
371
|
+
original.call(script, *args)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
visit 'index.html'
|
|
375
|
+
Percy.snapshot(page, 'Name', percyCSS: 'FROM_CALL')
|
|
376
|
+
|
|
377
|
+
expect(captured_serialize_call).to_not be_nil
|
|
378
|
+
serialized = JSON.parse(captured_serialize_call[/PercyDOM\.serialize\((.*)\)/m, 1])
|
|
379
|
+
# Config-only key still reaches serialize...
|
|
380
|
+
expect(serialized['enableJavaScript']).to eq(true)
|
|
381
|
+
# ...and the per-call option wins over the config value.
|
|
382
|
+
expect(serialized['percyCSS']).to eq('FROM_CALL')
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
it 'deep-merges nested config and per-snapshot options (sibling kept, leaf overridden)' do
|
|
386
|
+
# Config `snapshot` block carries a nested `discovery` hash; the per-call
|
|
387
|
+
# discovery only overrides one leaf, so the sibling key must survive.
|
|
388
|
+
stub_request(:get, "#{Percy::PERCY_SERVER_ADDRESS}/percy/healthcheck")
|
|
389
|
+
.to_return(
|
|
390
|
+
status: 200,
|
|
391
|
+
body: {
|
|
392
|
+
success: true,
|
|
393
|
+
config: {
|
|
394
|
+
'snapshot' => {
|
|
395
|
+
'discovery' => {'networkIdleTimeout' => 50, 'disableCache' => false},
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
}.to_json,
|
|
399
|
+
headers: {'x-percy-core-version': '1.0.0'},
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
stub_request(:get, "#{Percy::PERCY_SERVER_ADDRESS}/percy/dom.js")
|
|
403
|
+
.to_return(status: 200, body: fetch_script_string, headers: {})
|
|
404
|
+
|
|
405
|
+
stub_request(:post, 'http://localhost:5338/percy/snapshot')
|
|
406
|
+
.to_return(status: 200, body: '{"success":true}', headers: {})
|
|
407
|
+
|
|
408
|
+
captured_serialize_call = nil
|
|
409
|
+
allow(page).to receive(:execute_script).and_wrap_original do |original, script, *args|
|
|
410
|
+
captured_serialize_call = script if script.to_s.include?('PercyDOM.serialize')
|
|
411
|
+
original.call(script, *args)
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
visit 'index.html'
|
|
415
|
+
Percy.snapshot(page, 'Name', discovery: {disableCache: true})
|
|
416
|
+
|
|
417
|
+
expect(captured_serialize_call).to_not be_nil
|
|
418
|
+
serialized = JSON.parse(captured_serialize_call[/PercyDOM\.serialize\((.*)\)/m, 1])
|
|
419
|
+
# Sibling from config survives; per-call leaf overrides the config value.
|
|
420
|
+
expect(serialized['discovery']).to eq(
|
|
421
|
+
'networkIdleTimeout' => 50, 'disableCache' => true,
|
|
422
|
+
)
|
|
423
|
+
end
|
|
300
424
|
end
|
|
301
425
|
end
|
|
302
426
|
|
|
@@ -587,6 +711,42 @@ RSpec.describe Percy do
|
|
|
587
711
|
|
|
588
712
|
Percy.process_frame(driver, frame_element, {}, 'percy_dom_script')
|
|
589
713
|
end
|
|
714
|
+
|
|
715
|
+
it 'falls back to parent_frame when default_content fails in the inner ensure' do
|
|
716
|
+
allow(frame_element).to receive(:attribute).with('src')
|
|
717
|
+
.and_return('https://other.example.com/page')
|
|
718
|
+
allow(frame_element).to receive(:attribute).with('data-percy-element-id')
|
|
719
|
+
.and_return('elem-pf')
|
|
720
|
+
allow(driver).to receive(:execute_script).and_return(nil, {'html' => '<html/>'})
|
|
721
|
+
allow(switch_to).to receive(:default_content).and_raise(StandardError, 'dc boom')
|
|
722
|
+
expect(switch_to).to receive(:parent_frame).once
|
|
723
|
+
|
|
724
|
+
result = Percy.process_frame(driver, frame_element, {}, 'percy_dom_script')
|
|
725
|
+
expect(result['frameUrl']).to eq('https://other.example.com/page')
|
|
726
|
+
end
|
|
727
|
+
|
|
728
|
+
it 'swallows a parent_frame failure during inner-ensure recovery' do
|
|
729
|
+
allow(frame_element).to receive(:attribute).with('src')
|
|
730
|
+
.and_return('https://other.example.com/page')
|
|
731
|
+
allow(frame_element).to receive(:attribute).with('data-percy-element-id')
|
|
732
|
+
.and_return('elem-pf2')
|
|
733
|
+
allow(driver).to receive(:execute_script).and_return(nil, {'html' => '<html/>'})
|
|
734
|
+
allow(switch_to).to receive(:default_content).and_raise(StandardError, 'dc boom')
|
|
735
|
+
allow(switch_to).to receive(:parent_frame).and_raise(StandardError, 'pf boom')
|
|
736
|
+
|
|
737
|
+
expect { Percy.process_frame(driver, frame_element, {}, 'percy_dom_script') }
|
|
738
|
+
.to_not raise_error
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
it 'swallows a default_content failure in the outer rescue when frame switch fails' do
|
|
742
|
+
allow(frame_element).to receive(:attribute).with('src')
|
|
743
|
+
.and_return('https://other.example.com/page')
|
|
744
|
+
allow(switch_to).to receive(:frame).and_raise(StandardError, 'no such frame')
|
|
745
|
+
allow(switch_to).to receive(:default_content).and_raise(StandardError, 'dc boom')
|
|
746
|
+
|
|
747
|
+
result = Percy.process_frame(driver, frame_element, {}, 'percy_dom_script')
|
|
748
|
+
expect(result).to be_nil
|
|
749
|
+
end
|
|
590
750
|
end
|
|
591
751
|
|
|
592
752
|
describe '.get_serialized_dom' do
|
|
@@ -601,6 +761,9 @@ RSpec.describe Percy do
|
|
|
601
761
|
allow(switch_to).to receive(:frame)
|
|
602
762
|
allow(switch_to).to receive(:parent_frame)
|
|
603
763
|
allow(switch_to).to receive(:default_content)
|
|
764
|
+
# Readiness gate runs execute_async_script before serialize; stub it so
|
|
765
|
+
# these unit tests exercise serialization without a real browser.
|
|
766
|
+
allow(driver).to receive(:execute_async_script).and_return(nil)
|
|
604
767
|
end
|
|
605
768
|
|
|
606
769
|
it 'returns the serialized dom with cookies when no iframes present' do
|
|
@@ -801,6 +964,67 @@ RSpec.describe Percy do
|
|
|
801
964
|
expect(dom).to_not have_key('readiness_diagnostics')
|
|
802
965
|
expect(dom['html']).to eq('<html/>')
|
|
803
966
|
end
|
|
967
|
+
|
|
968
|
+
it 'raises the async-script timeout to match readiness timeoutMs and restores it after' do
|
|
969
|
+
timeouts = double('timeouts')
|
|
970
|
+
allow(manage).to receive(:timeouts).and_return(timeouts)
|
|
971
|
+
allow(timeouts).to receive(:script_timeout).and_return(30)
|
|
972
|
+
allow(driver).to receive(:execute_async_script).and_return(nil)
|
|
973
|
+
allow(driver).to receive(:execute_script).and_return({'html' => '<html/>'})
|
|
974
|
+
allow(driver).to receive(:current_url).and_return('http://main.example.com/')
|
|
975
|
+
allow(driver).to receive(:find_elements).and_return([])
|
|
976
|
+
|
|
977
|
+
# 8000ms -> 8s + 2s buffer is applied, then the previous 30s is restored.
|
|
978
|
+
expect(timeouts).to receive(:script_timeout=).with(10.0).ordered
|
|
979
|
+
expect(timeouts).to receive(:script_timeout=).with(30).ordered
|
|
980
|
+
|
|
981
|
+
Percy.get_serialized_dom(driver, readiness: {timeoutMs: 8000})
|
|
982
|
+
end
|
|
983
|
+
|
|
984
|
+
it 'proceeds when reading/setting the script timeout is unsupported' do
|
|
985
|
+
timeouts = double('timeouts')
|
|
986
|
+
allow(manage).to receive(:timeouts).and_return(timeouts)
|
|
987
|
+
allow(timeouts).to receive(:script_timeout).and_raise(StandardError, 'unsupported')
|
|
988
|
+
allow(driver).to receive(:execute_async_script).and_return(nil)
|
|
989
|
+
allow(driver).to receive(:execute_script).and_return({'html' => '<html/>'})
|
|
990
|
+
allow(driver).to receive(:current_url).and_return('http://main.example.com/')
|
|
991
|
+
allow(driver).to receive(:find_elements).and_return([])
|
|
992
|
+
|
|
993
|
+
expect { Percy.get_serialized_dom(driver, readiness: {timeoutMs: 5000}) }.to_not raise_error
|
|
994
|
+
end
|
|
995
|
+
|
|
996
|
+
it 'skips an iframe whose src cannot be resolved against the page url' do
|
|
997
|
+
frame = double('frame')
|
|
998
|
+
allow(frame).to receive(:attribute).with('src').and_return('ht!tp://%%%bad')
|
|
999
|
+
allow(driver).to receive(:execute_script).and_return({'html' => '<html/>'})
|
|
1000
|
+
allow(driver).to receive(:current_url).and_return('http://main.example.com/')
|
|
1001
|
+
allow(driver).to receive(:find_elements).and_return([frame])
|
|
1002
|
+
allow(URI).to receive(:join).and_raise(URI::InvalidURIError, 'bad uri')
|
|
1003
|
+
|
|
1004
|
+
dom = Percy.get_serialized_dom(driver, {}, percy_dom_script: 'script')
|
|
1005
|
+
expect(dom).to_not have_key('corsIframes')
|
|
1006
|
+
end
|
|
1007
|
+
|
|
1008
|
+
it 'logs and recovers when iframe processing raises unexpectedly' do
|
|
1009
|
+
allow(driver).to receive(:execute_script).and_return({'html' => '<html/>'})
|
|
1010
|
+
allow(driver).to receive(:current_url).and_return('http://main.example.com/')
|
|
1011
|
+
allow(driver).to receive(:find_elements).and_raise(StandardError, 'find boom')
|
|
1012
|
+
|
|
1013
|
+
dom = Percy.get_serialized_dom(driver, {}, percy_dom_script: 'script')
|
|
1014
|
+
# find_elements raised inside the iframe block; cookies are still attached.
|
|
1015
|
+
expect(dom['cookies']).to eq([])
|
|
1016
|
+
end
|
|
1017
|
+
|
|
1018
|
+
it 'swallows a secondary error when recovering from an iframe-processing failure' do
|
|
1019
|
+
allow(driver).to receive(:execute_script).and_return({'html' => '<html/>'})
|
|
1020
|
+
allow(driver).to receive(:current_url).and_return('http://main.example.com/')
|
|
1021
|
+
allow(driver).to receive(:find_elements).and_raise(StandardError, 'find boom')
|
|
1022
|
+
# default_content also fails during recovery -> inner rescue swallows it.
|
|
1023
|
+
allow(switch_to).to receive(:default_content).and_raise(StandardError, 'switch boom')
|
|
1024
|
+
|
|
1025
|
+
dom = Percy.get_serialized_dom(driver, {}, percy_dom_script: 'script')
|
|
1026
|
+
expect(dom['cookies']).to eq([])
|
|
1027
|
+
end
|
|
804
1028
|
end
|
|
805
1029
|
|
|
806
1030
|
describe '.change_window_dimension_and_wait' do
|
|
@@ -874,6 +1098,12 @@ RSpec.describe Percy do
|
|
|
874
1098
|
.with("window.dispatchEvent(new Event('resize'));")
|
|
875
1099
|
Percy.change_window_dimension_and_wait(driver, 375, 812, 1)
|
|
876
1100
|
end
|
|
1101
|
+
|
|
1102
|
+
it 'logs and swallows a TimeoutError when the resize event never fires' do
|
|
1103
|
+
allow(wait).to receive(:until).and_raise(Selenium::WebDriver::Error::TimeoutError)
|
|
1104
|
+
expect(Percy).to receive(:log).with(/Timed out waiting for window resize event/, 'debug')
|
|
1105
|
+
expect { Percy.change_window_dimension_and_wait(driver, 768, 1024, 1) }.to_not raise_error
|
|
1106
|
+
end
|
|
877
1107
|
end
|
|
878
1108
|
|
|
879
1109
|
describe '.capture_responsive_dom' do
|
|
@@ -976,6 +1206,21 @@ RSpec.describe Percy do
|
|
|
976
1206
|
expect(inner_nav).to receive(:refresh).once
|
|
977
1207
|
Percy.capture_responsive_dom(driver, {})
|
|
978
1208
|
end
|
|
1209
|
+
|
|
1210
|
+
it 'logs and continues when both the direct and fallback refresh fail' do
|
|
1211
|
+
allow(Percy).to receive(:get_responsive_widths).and_return([{'width' => 375}])
|
|
1212
|
+
allow(navigate).to receive(:refresh).and_raise(StandardError, 'direct refresh failed')
|
|
1213
|
+
|
|
1214
|
+
inner_browser = double('inner_browser')
|
|
1215
|
+
inner_drv = double('inner_driver', browser: inner_browser)
|
|
1216
|
+
inner_nav = double('inner_navigate')
|
|
1217
|
+
allow(driver).to receive(:driver).and_return(inner_drv)
|
|
1218
|
+
allow(inner_browser).to receive(:navigate).and_return(inner_nav)
|
|
1219
|
+
allow(inner_nav).to receive(:refresh).and_raise(StandardError, 'fallback refresh failed')
|
|
1220
|
+
|
|
1221
|
+
expect(Percy).to receive(:log).with(/Failed to refresh page/, 'debug')
|
|
1222
|
+
expect { Percy.capture_responsive_dom(driver, {}) }.to_not raise_error
|
|
1223
|
+
end
|
|
979
1224
|
end
|
|
980
1225
|
|
|
981
1226
|
# -----------------------------------------------------------------------
|
|
@@ -1059,6 +1304,14 @@ RSpec.describe Percy do
|
|
|
1059
1304
|
end
|
|
1060
1305
|
end
|
|
1061
1306
|
|
|
1307
|
+
# :nocov:
|
|
1308
|
+
# This whole describe is a live end-to-end test (xit, permanently skipped on
|
|
1309
|
+
# CI): it depends on the real @percy/cli test-mode `/test/requests` endpoint
|
|
1310
|
+
# being populated, which is not deterministic under `percy exec --testing`. It
|
|
1311
|
+
# exercises no lib lines not already covered by the stubbed snapshot specs.
|
|
1312
|
+
# Because it never executes, its body would otherwise count as uncovered lines
|
|
1313
|
+
# against the SimpleCov 100% gate, so it is wrapped in `# :nocov:` to exclude it
|
|
1314
|
+
# from coverage measurement while keeping the documented scenario in the suite.
|
|
1062
1315
|
RSpec.describe Percy, type: :feature do
|
|
1063
1316
|
before(:each) do
|
|
1064
1317
|
WebMock.reset!
|
|
@@ -1067,7 +1320,7 @@ RSpec.describe Percy, type: :feature do
|
|
|
1067
1320
|
end
|
|
1068
1321
|
|
|
1069
1322
|
describe 'integration', type: :feature do
|
|
1070
|
-
|
|
1323
|
+
xit 'sends snapshots to percy server' do
|
|
1071
1324
|
visit 'index.html'
|
|
1072
1325
|
Percy.snapshot(page, 'Name', widths: [375])
|
|
1073
1326
|
sleep 5 # wait for percy server to process
|
|
@@ -1086,6 +1339,7 @@ RSpec.describe Percy, type: :feature do
|
|
|
1086
1339
|
end
|
|
1087
1340
|
end
|
|
1088
1341
|
end
|
|
1342
|
+
# :nocov:
|
|
1089
1343
|
|
|
1090
1344
|
RSpec.describe Percy do
|
|
1091
1345
|
describe '.percy_screenshot' do
|
|
@@ -1379,4 +1633,105 @@ RSpec.describe Percy do
|
|
|
1379
1633
|
end
|
|
1380
1634
|
end
|
|
1381
1635
|
end
|
|
1636
|
+
|
|
1637
|
+
RSpec.describe Percy do
|
|
1638
|
+
before(:each) do
|
|
1639
|
+
# Allow loopback so Capybara's live selenium session (127.0.0.1:4444) can be
|
|
1640
|
+
# torn down at process exit even when this block's `before` is the last one
|
|
1641
|
+
# to run under random ordering; percy endpoints are stubbed explicitly.
|
|
1642
|
+
WebMock.disable_net_connect!(allow: '127.0.0.1')
|
|
1643
|
+
Percy._clear_cache!
|
|
1644
|
+
end
|
|
1645
|
+
|
|
1646
|
+
describe '.snapshot (mocked driver)' do
|
|
1647
|
+
let(:driver) { double('driver') }
|
|
1648
|
+
let(:manage) { double('manage') }
|
|
1649
|
+
let(:switch_to) { double('switch_to') }
|
|
1650
|
+
|
|
1651
|
+
def stub_web_snapshot_healthcheck
|
|
1652
|
+
stub_request(:get, "#{Percy::PERCY_SERVER_ADDRESS}/percy/healthcheck")
|
|
1653
|
+
.to_return(status: 200, body: '{"success":true,"type":"web"}',
|
|
1654
|
+
headers: {'x-percy-core-version': '1.0.0'},)
|
|
1655
|
+
stub_request(:get, "#{Percy::PERCY_SERVER_ADDRESS}/percy/dom.js")
|
|
1656
|
+
.to_return(status: 200, body: 'window.PercyDOM = {};', headers: {})
|
|
1657
|
+
end
|
|
1658
|
+
|
|
1659
|
+
before(:each) do
|
|
1660
|
+
stub_request(:post, "#{Percy::PERCY_SERVER_ADDRESS}/percy/log")
|
|
1661
|
+
.to_return(status: 200, body: '', headers: {})
|
|
1662
|
+
allow(driver).to receive(:manage).and_return(manage)
|
|
1663
|
+
allow(manage).to receive(:all_cookies).and_return([])
|
|
1664
|
+
allow(driver).to receive(:respond_to?).with(:driver).and_return(false)
|
|
1665
|
+
allow(driver).to receive(:switch_to).and_return(switch_to)
|
|
1666
|
+
allow(switch_to).to receive(:default_content)
|
|
1667
|
+
allow(driver).to receive(:execute_async_script).and_return(nil)
|
|
1668
|
+
allow(driver).to receive(:current_url).and_return('http://127.0.0.1:3003/index.html')
|
|
1669
|
+
allow(driver).to receive(:find_elements).and_return([])
|
|
1670
|
+
allow(driver).to receive(:execute_script) do |script|
|
|
1671
|
+
{'html' => '<html/>'} if script.to_s.include?('PercyDOM.serialize')
|
|
1672
|
+
end
|
|
1673
|
+
end
|
|
1674
|
+
|
|
1675
|
+
it 'serializes the dom and posts to /percy/snapshot on the non-responsive path' do
|
|
1676
|
+
stub_web_snapshot_healthcheck
|
|
1677
|
+
stub_request(:post, "#{Percy::PERCY_SERVER_ADDRESS}/percy/snapshot")
|
|
1678
|
+
.to_return(status: 200, body: '{"success":true}')
|
|
1679
|
+
|
|
1680
|
+
Percy.snapshot(driver, 'MockedShot')
|
|
1681
|
+
|
|
1682
|
+
expect(WebMock).to have_requested(:post, "#{Percy::PERCY_SERVER_ADDRESS}/percy/snapshot")
|
|
1683
|
+
.with { |req| JSON.parse(req.body)['name'] == 'MockedShot' }.once
|
|
1684
|
+
end
|
|
1685
|
+
|
|
1686
|
+
it 'logs the failure when the snapshot response success is false' do
|
|
1687
|
+
stub_web_snapshot_healthcheck
|
|
1688
|
+
stub_request(:post, "#{Percy::PERCY_SERVER_ADDRESS}/percy/snapshot")
|
|
1689
|
+
.to_return(status: 200, body: '{"success":false,"error":"server rejected"}')
|
|
1690
|
+
|
|
1691
|
+
# body['success'] is false -> raise body['error'] -> swallowed + logged.
|
|
1692
|
+
expect { Percy.snapshot(driver, 'RejectedShot') }
|
|
1693
|
+
.to output(/Could not take DOM snapshot 'RejectedShot'/).to_stdout
|
|
1694
|
+
end
|
|
1695
|
+
end
|
|
1696
|
+
|
|
1697
|
+
describe '.get_browser_instance' do
|
|
1698
|
+
it 'unwraps a Capybara-style session (driver.driver.browser.manage)' do
|
|
1699
|
+
inner_manage = double('inner_manage')
|
|
1700
|
+
inner_browser = double('inner_browser', manage: inner_manage)
|
|
1701
|
+
inner_driver = double('inner_driver')
|
|
1702
|
+
session = double('session')
|
|
1703
|
+
allow(session).to receive(:respond_to?).with(:driver).and_return(true)
|
|
1704
|
+
allow(session).to receive(:driver).and_return(inner_driver)
|
|
1705
|
+
allow(inner_driver).to receive(:respond_to?).with(:browser).and_return(true)
|
|
1706
|
+
allow(inner_driver).to receive(:browser).and_return(inner_browser)
|
|
1707
|
+
|
|
1708
|
+
expect(Percy.get_browser_instance(session)).to eq(inner_manage)
|
|
1709
|
+
end
|
|
1710
|
+
|
|
1711
|
+
it 'uses driver.manage for a plain WebDriver session' do
|
|
1712
|
+
manage = double('manage')
|
|
1713
|
+
driver = double('driver', manage: manage)
|
|
1714
|
+
allow(driver).to receive(:respond_to?).with(:driver).and_return(false)
|
|
1715
|
+
|
|
1716
|
+
expect(Percy.get_browser_instance(driver)).to eq(manage)
|
|
1717
|
+
end
|
|
1718
|
+
end
|
|
1719
|
+
|
|
1720
|
+
describe '.get_driver_metadata' do
|
|
1721
|
+
it 'wraps the driver in a DriverMetaData instance' do
|
|
1722
|
+
driver = double('driver')
|
|
1723
|
+
expect(Percy.get_driver_metadata(driver)).to be_a(DriverMetaData)
|
|
1724
|
+
end
|
|
1725
|
+
end
|
|
1726
|
+
|
|
1727
|
+
describe '.log' do
|
|
1728
|
+
it 'prints the CLI-send failure when PERCY_DEBUG is enabled' do
|
|
1729
|
+
stub_const('Percy::PERCY_DEBUG', true)
|
|
1730
|
+
stub_request(:post, "#{Percy::PERCY_SERVER_ADDRESS}/percy/log").to_raise(StandardError)
|
|
1731
|
+
|
|
1732
|
+
expect { Percy.log('hello', 'debug') }
|
|
1733
|
+
.to output(/Sending log to CLI Failed/).to_stdout
|
|
1734
|
+
end
|
|
1735
|
+
end
|
|
1736
|
+
end
|
|
1382
1737
|
# rubocop:enable RSpec/MultipleDescribes
|
data/spec/spec_helper.rb
CHANGED
|
@@ -35,8 +35,35 @@ RSpec.configure do |config|
|
|
|
35
35
|
Kernel.srand config.seed
|
|
36
36
|
|
|
37
37
|
# See https://github.com/teamcapybara/capybara#selecting-the-driver for other options
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
# Default to Firefox headless (matches CI), but when a Chromium/Chrome binary is
|
|
39
|
+
# provided via CHROME_BIN (e.g. the containerised e2e image), register and use a
|
|
40
|
+
# headless Chrome driver pointing at it instead.
|
|
41
|
+
if ENV['CHROME_BIN'] && !ENV['CHROME_BIN'].empty?
|
|
42
|
+
Capybara.register_driver :selenium_chrome_headless_bin do |app|
|
|
43
|
+
options = Selenium::WebDriver::Chrome::Options.new
|
|
44
|
+
options.binary = ENV['CHROME_BIN']
|
|
45
|
+
options.add_argument('--headless=new')
|
|
46
|
+
options.add_argument('--no-sandbox')
|
|
47
|
+
options.add_argument('--disable-gpu')
|
|
48
|
+
options.add_argument('--disable-dev-shm-usage')
|
|
49
|
+
Capybara::Selenium::Driver.new(app, browser: :chrome, options: options)
|
|
50
|
+
end
|
|
51
|
+
Capybara.default_driver = :selenium_chrome_headless_bin
|
|
52
|
+
Capybara.javascript_driver = :selenium_chrome_headless_bin
|
|
53
|
+
else
|
|
54
|
+
Capybara.default_driver = :selenium_headless
|
|
55
|
+
Capybara.javascript_driver = :selenium_headless
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Capybara's built-in :selenium_headless driver still passes `options:` to
|
|
59
|
+
# driver init, which newer selenium-webdriver logs as a [DEPRECATION]
|
|
60
|
+
# [:browser_options] warning on first use. The suite asserts exact stdout
|
|
61
|
+
# via `output(...).to_stdout`, so any example that first boots the driver
|
|
62
|
+
# inside such a block fails on that extra line (seed-dependent). Silence
|
|
63
|
+
# just that deprecation id.
|
|
64
|
+
if Selenium::WebDriver.logger.respond_to?(:ignore)
|
|
65
|
+
Selenium::WebDriver.logger.ignore(:browser_options)
|
|
66
|
+
end
|
|
40
67
|
|
|
41
68
|
# Setup for Capybara to test Jekyll static files served by Rack
|
|
42
69
|
Capybara.server_port = 3003
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: percy-selenium
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.1.
|
|
4
|
+
version: 1.1.4.pre.beta.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Perceptual Inc.
|
|
@@ -110,6 +110,7 @@ files:
|
|
|
110
110
|
- ".github/workflows/stale.yml"
|
|
111
111
|
- ".github/workflows/test.yml"
|
|
112
112
|
- ".gitignore"
|
|
113
|
+
- ".npmrc"
|
|
113
114
|
- ".rspec"
|
|
114
115
|
- ".rubocop.yml"
|
|
115
116
|
- ".yardopts"
|