percy-selenium 2.2.1b2__tar.gz → 2.2.2b0__tar.gz

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.
Files changed (25) hide show
  1. {percy_selenium-2.2.1b2 → percy_selenium-2.2.2b0}/PKG-INFO +8 -1
  2. {percy_selenium-2.2.1b2 → percy_selenium-2.2.2b0}/README.md +7 -0
  3. {percy_selenium-2.2.1b2 → percy_selenium-2.2.2b0}/percy/cache.py +18 -12
  4. {percy_selenium-2.2.1b2 → percy_selenium-2.2.2b0}/percy/snapshot.py +45 -6
  5. percy_selenium-2.2.2b0/percy/version.py +1 -0
  6. {percy_selenium-2.2.1b2 → percy_selenium-2.2.2b0}/percy_selenium.egg-info/PKG-INFO +8 -1
  7. {percy_selenium-2.2.1b2 → percy_selenium-2.2.2b0}/tests/test_cache.py +29 -0
  8. {percy_selenium-2.2.1b2 → percy_selenium-2.2.2b0}/tests/test_snapshot_units.py +105 -1
  9. percy_selenium-2.2.1b2/percy/version.py +0 -1
  10. {percy_selenium-2.2.1b2 → percy_selenium-2.2.2b0}/LICENSE +0 -0
  11. {percy_selenium-2.2.1b2 → percy_selenium-2.2.2b0}/percy/__init__.py +0 -0
  12. {percy_selenium-2.2.1b2 → percy_selenium-2.2.2b0}/percy/driver_metadata.py +0 -0
  13. {percy_selenium-2.2.1b2 → percy_selenium-2.2.2b0}/percy/exception.py +0 -0
  14. {percy_selenium-2.2.1b2 → percy_selenium-2.2.2b0}/percy/robot_library.py +0 -0
  15. {percy_selenium-2.2.1b2 → percy_selenium-2.2.2b0}/percy_selenium.egg-info/SOURCES.txt +0 -0
  16. {percy_selenium-2.2.1b2 → percy_selenium-2.2.2b0}/percy_selenium.egg-info/dependency_links.txt +0 -0
  17. {percy_selenium-2.2.1b2 → percy_selenium-2.2.2b0}/percy_selenium.egg-info/not-zip-safe +0 -0
  18. {percy_selenium-2.2.1b2 → percy_selenium-2.2.2b0}/percy_selenium.egg-info/requires.txt +0 -0
  19. {percy_selenium-2.2.1b2 → percy_selenium-2.2.2b0}/percy_selenium.egg-info/top_level.txt +0 -0
  20. {percy_selenium-2.2.1b2 → percy_selenium-2.2.2b0}/setup.cfg +0 -0
  21. {percy_selenium-2.2.1b2 → percy_selenium-2.2.2b0}/setup.py +0 -0
  22. {percy_selenium-2.2.1b2 → percy_selenium-2.2.2b0}/tests/test_driver_metadata.py +0 -0
  23. {percy_selenium-2.2.1b2 → percy_selenium-2.2.2b0}/tests/test_init.py +0 -0
  24. {percy_selenium-2.2.1b2 → percy_selenium-2.2.2b0}/tests/test_robot_library.py +0 -0
  25. {percy_selenium-2.2.1b2 → percy_selenium-2.2.2b0}/tests/test_snapshot.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: percy-selenium
3
- Version: 2.2.1b2
3
+ Version: 2.2.2b0
4
4
  Summary: Python client for visual testing with Percy
5
5
  Home-page: https://github.com/percy/percy-selenium-python
6
6
  Author: Perceptual Inc.
@@ -95,6 +95,13 @@ $ percy exec -- [python test command]
95
95
  - `name` (**required**) - The snapshot name; must be unique to each snapshot
96
96
  - `**kwargs` - [See per-snapshot configuration options](https://www.browserstack.com/docs/percy/take-percy-snapshots/overview#per-snapshot-configuration)
97
97
 
98
+ ### Cookies
99
+
100
+ By default the SDK sends the browser's cookies with each snapshot so Percy can load pages and
101
+ assets that need a logged-in session during asset discovery. To stop sending cookies, set
102
+ `PERCY_DO_NOT_USE_CAPTURED_COOKIES=true`. Assets behind authentication may then fail to load
103
+ and snapshots can render incorrectly; the SDK logs a warning once when this is enabled.
104
+
98
105
  ### Migrating Config
99
106
 
100
107
  If you have a previous Percy configuration file, migrate it to the newest version with the
@@ -60,6 +60,13 @@ $ percy exec -- [python test command]
60
60
  - `name` (**required**) - The snapshot name; must be unique to each snapshot
61
61
  - `**kwargs` - [See per-snapshot configuration options](https://www.browserstack.com/docs/percy/take-percy-snapshots/overview#per-snapshot-configuration)
62
62
 
63
+ ### Cookies
64
+
65
+ By default the SDK sends the browser's cookies with each snapshot so Percy can load pages and
66
+ assets that need a logged-in session during asset discovery. To stop sending cookies, set
67
+ `PERCY_DO_NOT_USE_CAPTURED_COOKIES=true`. Assets behind authentication may then fail to load
68
+ and snapshots can render incorrectly; the SDK logs a warning once when this is enabled.
69
+
63
70
  ### Migrating Config
64
71
 
65
72
  If you have a previous Percy configuration file, migrate it to the newest version with the
@@ -1,9 +1,12 @@
1
+ import threading
1
2
  import time
2
3
 
3
4
  class Cache:
4
5
  CACHE = {}
5
6
  CACHE_TIMEOUT = 5 * 60 # 300 seconds
6
7
  TIMEOUT_KEY = 'last_access_time'
8
+ # Guards CACHE so parallel runners sharing a process can't mutate it mid-iteration
9
+ LOCK = threading.RLock()
7
10
 
8
11
  # Caching Keys
9
12
  capabilities = 'capabilities'
@@ -19,24 +22,27 @@ class Cache:
19
22
  @classmethod
20
23
  def set_cache(cls, session_id, property, value):
21
24
  cls.check_types(session_id, property)
22
- session = cls.CACHE.get(session_id, {})
23
- session[cls.TIMEOUT_KEY] = time.time()
24
- session[property] = value
25
- cls.CACHE[session_id] = session
25
+ with cls.LOCK:
26
+ session = cls.CACHE.get(session_id, {})
27
+ session[cls.TIMEOUT_KEY] = time.time()
28
+ session[property] = value
29
+ cls.CACHE[session_id] = session
26
30
 
27
31
  @classmethod
28
32
  def get_cache(cls, session_id, property):
29
33
  cls.cleanup_cache()
30
34
  cls.check_types(session_id, property)
31
- session = cls.CACHE.get(session_id, {})
32
- return session.get(property, None)
35
+ with cls.LOCK:
36
+ session = cls.CACHE.get(session_id, {})
37
+ return session.get(property, None)
33
38
 
34
39
  @classmethod
35
40
  def cleanup_cache(cls):
36
41
  now = time.time()
37
- session_ids = []
38
- for session_id, session in cls.CACHE.items():
39
- timestamp = session[cls.TIMEOUT_KEY]
40
- if now - timestamp >= cls.CACHE_TIMEOUT:
41
- session_ids.append(session_id)
42
- list(map(cls.CACHE.pop, session_ids))
42
+ with cls.LOCK:
43
+ session_ids = [
44
+ session_id for session_id, session in cls.CACHE.items()
45
+ if now - session[cls.TIMEOUT_KEY] >= cls.CACHE_TIMEOUT
46
+ ]
47
+ for session_id in session_ids:
48
+ cls.CACHE.pop(session_id, None)
@@ -2,6 +2,7 @@
2
2
  import os
3
3
  import platform
4
4
  import json
5
+ import tempfile
5
6
  from contextlib import contextmanager
6
7
  from functools import lru_cache
7
8
  from time import sleep
@@ -35,6 +36,9 @@ try:
35
36
  RESIZE_SETTLE_SECONDS = float(os.environ.get('PERCY_RESIZE_SETTLE_SECONDS') or 0.5)
36
37
  except (TypeError, ValueError):
37
38
  RESIZE_SETTLE_SECONDS = 0.5
39
+ # Set to true to stop sending browser cookies to the CLI (read per snapshot)
40
+ DO_NOT_USE_CAPTURED_COOKIES_ENV = 'PERCY_DO_NOT_USE_CAPTURED_COOKIES'
41
+ _cookie_opt_out_warned = False
38
42
  # for logging
39
43
  LABEL = '[\u001b[35m' + ('percy:python' if PERCY_DEBUG else 'percy') + '\u001b[39m]'
40
44
  CDP_SUPPORT_SELENIUM = (str(SELENIUM_VERSION)[0].isdigit() and int(
@@ -52,6 +56,25 @@ def log(message, lvl = 'info'):
52
56
  if lvl != 'debug' or PERCY_DEBUG:
53
57
  print(message)
54
58
 
59
+ def get_cookies_for_snapshot(driver):
60
+ """Collect browser cookies so the CLI can load authenticated assets during
61
+ discovery. Honours the PERCY_DO_NOT_USE_CAPTURED_COOKIES opt-out and never
62
+ raises: a cookie read failure only means no cookies are forwarded."""
63
+ global _cookie_opt_out_warned # pylint: disable=global-statement
64
+ if _get_bool_env(DO_NOT_USE_CAPTURED_COOKIES_ENV):
65
+ if not _cookie_opt_out_warned:
66
+ _cookie_opt_out_warned = True
67
+ log('Not sending browser cookies to Percy because '
68
+ f'{DO_NOT_USE_CAPTURED_COOKIES_ENV} is set. Pages or assets that need a '
69
+ 'logged-in session may fail to load during asset discovery, so snapshots '
70
+ 'can render incorrectly.', 'warn')
71
+ return []
72
+ try:
73
+ return driver.get_cookies()
74
+ except Exception as e: # pylint: disable=broad-except
75
+ log(f'Could not read browser cookies, continuing without them: {e}', 'debug')
76
+ return []
77
+
55
78
  # Check if Percy is enabled, caching the result so it is only checked once
56
79
  @lru_cache(maxsize=None)
57
80
  def is_percy_enabled():
@@ -991,11 +1014,7 @@ def capture_responsive_dom(driver, cookies, config, percy_dom_script=None, **kwa
991
1014
  dom_snapshots.append(dom_snapshot)
992
1015
  # Optional debug dump — gated to avoid polluting the user's CWD on every CI run.
993
1016
  if PERCY_DEBUG:
994
- try:
995
- with open("output_file.json", "w", encoding="utf-8") as file_handle:
996
- json.dump(dom_snapshots, file_handle, indent=4)
997
- except Exception as e: # pylint: disable=broad-except
998
- log(f"Could not write debug snapshot dump: {type(e).__name__}: {e}", "debug")
1017
+ _write_responsive_debug_dump(dom_snapshots)
999
1018
  # Restore the original window size after capture. No snapshot follows, so
1000
1019
  # skip the resize-event poll (wait_for_resize=False) — it serves no purpose
1001
1020
  # here and is the call that hangs geckodriver in CI.
@@ -1003,6 +1022,26 @@ def capture_responsive_dom(driver, cookies, config, percy_dom_script=None, **kwa
1003
1022
  driver, current_width, current_height, resize_count + 1, wait_for_resize=False)
1004
1023
  return dom_snapshots
1005
1024
 
1025
+ def _write_responsive_debug_dump(dom_snapshots):
1026
+ # Owner-only temp file with an unpredictable name; cookies are left out
1027
+ # because they're not needed to debug the captured DOM.
1028
+ try:
1029
+ redacted = [
1030
+ {k: v for k, v in snap.items() if k != 'cookies'} if isinstance(snap, dict) else snap
1031
+ for snap in dom_snapshots
1032
+ ]
1033
+ fd, path = tempfile.mkstemp(prefix='percy-responsive-dom-', suffix='.json')
1034
+ try:
1035
+ with os.fdopen(fd, "w", encoding="utf-8") as file_handle:
1036
+ json.dump(redacted, file_handle, indent=4)
1037
+ except Exception:
1038
+ # Don't leave a partially written dump behind
1039
+ os.remove(path)
1040
+ raise
1041
+ log(f"Wrote responsive snapshot debug dump to {path}", "debug")
1042
+ except Exception as e: # pylint: disable=broad-except
1043
+ log(f"Could not write debug snapshot dump: {type(e).__name__}: {e}", "debug")
1044
+
1006
1045
  def is_responsive_snapshot_capture(config, **kwargs):
1007
1046
  # Don't run resposive snapshot capture when defer uploads is enabled
1008
1047
  if 'percy' in config and config['percy'].get('deferUploads', False): return False
@@ -1028,7 +1067,7 @@ def percy_snapshot(driver, name, **kwargs):
1028
1067
  # Expose closed shadow roots via CDP before serialization so PercyDOM
1029
1068
  # can find them through the WeakMap (Chromium-only; non-Chromium no-ops).
1030
1069
  expose_closed_shadow_roots(driver)
1031
- cookies = driver.get_cookies()
1070
+ cookies = get_cookies_for_snapshot(driver)
1032
1071
 
1033
1072
  # Serialize and capture the DOM
1034
1073
  if is_responsive_snapshot_capture(data['config'], **kwargs):
@@ -0,0 +1 @@
1
+ __version__ = "2.2.2-beta.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: percy-selenium
3
- Version: 2.2.1b2
3
+ Version: 2.2.2b0
4
4
  Summary: Python client for visual testing with Percy
5
5
  Home-page: https://github.com/percy/percy-selenium-python
6
6
  Author: Perceptual Inc.
@@ -95,6 +95,13 @@ $ percy exec -- [python test command]
95
95
  - `name` (**required**) - The snapshot name; must be unique to each snapshot
96
96
  - `**kwargs` - [See per-snapshot configuration options](https://www.browserstack.com/docs/percy/take-percy-snapshots/overview#per-snapshot-configuration)
97
97
 
98
+ ### Cookies
99
+
100
+ By default the SDK sends the browser's cookies with each snapshot so Percy can load pages and
101
+ assets that need a logged-in session during asset discovery. To stop sending cookies, set
102
+ `PERCY_DO_NOT_USE_CAPTURED_COOKIES=true`. Assets behind authentication may then fail to load
103
+ and snapshots can render incorrectly; the SDK logs a warning once when this is enabled.
104
+
98
105
  ### Migrating Config
99
106
 
100
107
  If you have a previous Percy configuration file, migrate it to the newest version with the
@@ -1,4 +1,5 @@
1
1
  # . # pylint: disable=[arguments-differ, protected-access]
2
+ import threading
2
3
  import time
3
4
  import unittest
4
5
  from unittest.mock import patch
@@ -57,3 +58,31 @@ class TestCache(unittest.TestCase):
57
58
  self.assertIn(self.session_id, self.cache.CACHE)
58
59
  self.cache.cleanup_cache()
59
60
  self.assertNotIn(self.session_id, self.cache.CACHE)
61
+
62
+ def test_cleanup_cache_blocks_concurrent_writers_while_iterating(self):
63
+ cache = self.cache
64
+ writer_blocked = []
65
+ writers = []
66
+
67
+ class InterleavingDict(dict):
68
+ # Simulates another thread writing to the cache mid-iteration
69
+ def items(self):
70
+ writer = threading.Thread(
71
+ target=cache.set_cache,
72
+ args=('other_session', Cache.capabilities, {}))
73
+ writer.start()
74
+ writers.append(writer)
75
+ writer.join(timeout=0.2)
76
+ writer_blocked.append(writer.is_alive())
77
+ for item in list(super().items()):
78
+ yield item
79
+
80
+ original = cache.CACHE
81
+ cache.CACHE = InterleavingDict(original)
82
+ try:
83
+ cache.cleanup_cache() # must not raise "dictionary changed size during iteration"
84
+ writers[0].join(timeout=5)
85
+ self.assertEqual(writer_blocked, [True])
86
+ self.assertIn('other_session', cache.CACHE)
87
+ finally:
88
+ cache.CACHE = original
@@ -5,6 +5,9 @@ target branches the end-to-end suite in test_snapshot.py does not reach."""
5
5
  import importlib
6
6
  import json
7
7
  import os
8
+ import shutil
9
+ import stat
10
+ import tempfile
8
11
  import unittest
9
12
  from unittest.mock import patch, MagicMock, Mock
10
13
 
@@ -471,6 +474,13 @@ class TestGetSerializedDomPageUrlFailure(unittest.TestCase):
471
474
 
472
475
 
473
476
  class TestResponsiveDebugDump(unittest.TestCase):
477
+ def setUp(self):
478
+ self.tmpdir = tempfile.mkdtemp()
479
+ patcher = patch('percy.snapshot.tempfile.tempdir', self.tmpdir)
480
+ patcher.start()
481
+ self.addCleanup(patcher.stop)
482
+ self.addCleanup(shutil.rmtree, self.tmpdir, True)
483
+
474
484
  def tearDown(self):
475
485
  if os.path.exists('output_file.json'):
476
486
  os.remove('output_file.json')
@@ -489,7 +499,32 @@ class TestResponsiveDebugDump(unittest.TestCase):
489
499
  result = local.capture_responsive_dom(
490
500
  self._driver(), {}, {}, percy_dom_script='PERCY_DOM')
491
501
  self.assertEqual(result, [])
492
- self.assertTrue(os.path.exists('output_file.json'))
502
+ self.assertEqual(len(os.listdir(self.tmpdir)), 1)
503
+
504
+ @patch('percy.snapshot.PERCY_DEBUG', True)
505
+ @patch('percy.snapshot.change_window_dimension_and_wait', MagicMock())
506
+ @patch('percy.snapshot._wait_for_ready', MagicMock(return_value=None))
507
+ @patch('percy.snapshot.get_responsive_widths',
508
+ MagicMock(return_value=[{'width': 375}]))
509
+ @patch('percy.snapshot.get_serialized_dom')
510
+ def test_debug_dump_is_private_temp_file_without_cookies(self, mock_dom):
511
+ mock_dom.return_value = {'html': '<p/>', 'cookies': [{'name': 'sid', 'value': 's3cr3t'}]}
512
+ result = local.capture_responsive_dom(
513
+ self._driver(), [{'name': 'sid', 'value': 's3cr3t'}], {},
514
+ percy_dom_script='PERCY_DOM')
515
+ # The snapshot sent to the CLI still carries its cookies
516
+ self.assertEqual(result[0]['cookies'], [{'name': 'sid', 'value': 's3cr3t'}])
517
+ # No predictable file is dropped into the working directory
518
+ self.assertFalse(os.path.exists('output_file.json'))
519
+ files = os.listdir(self.tmpdir)
520
+ self.assertEqual(len(files), 1)
521
+ path = os.path.join(self.tmpdir, files[0])
522
+ if os.name == 'posix':
523
+ self.assertEqual(stat.S_IMODE(os.stat(path).st_mode), 0o600)
524
+ with open(path, encoding='utf-8') as file_handle:
525
+ content = file_handle.read()
526
+ self.assertNotIn('s3cr3t', content)
527
+ self.assertEqual(json.loads(content), [{'html': '<p/>', 'width': 375}])
493
528
 
494
529
  @patch('percy.snapshot.PERCY_DEBUG', True)
495
530
  @patch('percy.snapshot.change_window_dimension_and_wait', MagicMock())
@@ -500,6 +535,75 @@ class TestResponsiveDebugDump(unittest.TestCase):
500
535
  result = local.capture_responsive_dom(
501
536
  self._driver(), {}, {}, percy_dom_script='PERCY_DOM')
502
537
  self.assertEqual(result, [])
538
+ # The partially written dump is removed
539
+ self.assertEqual(os.listdir(self.tmpdir), [])
540
+
541
+
542
+ class TestCookieForwarding(unittest.TestCase):
543
+ SESSION_COOKIES = [
544
+ {'name': 'session_id', 'value': 'abc', 'domain': 'example.com', 'secure': True},
545
+ {'name': 'auth_token', 'value': 'xyz', 'domain': 'example.com', 'secure': True},
546
+ ]
547
+
548
+ def setUp(self):
549
+ patcher = patch('percy.snapshot._cookie_opt_out_warned', False)
550
+ patcher.start()
551
+ self.addCleanup(patcher.stop)
552
+
553
+ def _driver(self):
554
+ driver = MagicMock()
555
+ driver.get_cookies.return_value = list(self.SESSION_COOKIES)
556
+ driver.current_url = 'https://example.com/'
557
+ return driver
558
+
559
+ @patch('percy.snapshot.is_percy_enabled',
560
+ MagicMock(return_value={'session_type': 'web', 'config': {}, 'widths': {}}))
561
+ @patch('percy.snapshot.fetch_percy_dom', MagicMock(return_value='PERCY_DOM'))
562
+ @patch('percy.snapshot.expose_closed_shadow_roots', MagicMock())
563
+ @patch('percy.snapshot.get_serialized_dom')
564
+ @patch('percy.snapshot.requests.post')
565
+ def _take_snapshots(self, mock_post, mock_dom, count=1):
566
+ mock_dom.side_effect = lambda _d, cookies, **_k: {'html': '<p/>', 'cookies': cookies}
567
+ mock_post.return_value.json.return_value = {'success': True}
568
+ for i in range(count):
569
+ local.percy_snapshot(self._driver(), f'snap {i}')
570
+ return [c for c in mock_post.call_args_list if c.args[0].endswith('/percy/snapshot')]
571
+
572
+ @patch.dict(os.environ, {}, clear=False)
573
+ def test_default_forwards_session_cookies_unchanged(self):
574
+ os.environ.pop('PERCY_DO_NOT_USE_CAPTURED_COOKIES', None)
575
+ posts = self._take_snapshots()
576
+ self.assertEqual(posts[0].kwargs['json']['dom_snapshot']['cookies'],
577
+ self.SESSION_COOKIES)
578
+
579
+ @patch.dict(os.environ, {'PERCY_DO_NOT_USE_CAPTURED_COOKIES': 'true'})
580
+ @patch('percy.snapshot.log')
581
+ def test_opt_out_sends_no_cookies_and_warns_once(self, mock_log):
582
+ posts = self._take_snapshots(count=2) # pylint: disable=unexpected-keyword-arg
583
+ self.assertEqual(len(posts), 2)
584
+ for post in posts:
585
+ self.assertEqual(post.kwargs['json']['dom_snapshot']['cookies'], [])
586
+ warnings = [c for c in mock_log.call_args_list
587
+ if len(c.args) > 1 and c.args[1] == 'warn']
588
+ self.assertEqual(len(warnings), 1)
589
+ self.assertEqual(
590
+ warnings[0].args[0],
591
+ 'Not sending browser cookies to Percy because PERCY_DO_NOT_USE_CAPTURED_COOKIES '
592
+ 'is set. Pages or assets that need a logged-in session may fail to load during '
593
+ 'asset discovery, so snapshots can render incorrectly.')
594
+
595
+ @patch.dict(os.environ, {'PERCY_DO_NOT_USE_CAPTURED_COOKIES': 'false'})
596
+ def test_opt_out_false_still_forwards(self):
597
+ driver = self._driver()
598
+ self.assertEqual(local.get_cookies_for_snapshot(driver), self.SESSION_COOKIES)
599
+
600
+ @patch.dict(os.environ, {}, clear=False)
601
+ @patch('percy.snapshot.log')
602
+ def test_cookie_read_failure_is_not_raised(self, _mock_log):
603
+ os.environ.pop('PERCY_DO_NOT_USE_CAPTURED_COOKIES', None)
604
+ driver = self._driver()
605
+ driver.get_cookies.side_effect = Exception('no cookies for you')
606
+ self.assertEqual(local.get_cookies_for_snapshot(driver), [])
503
607
 
504
608
 
505
609
  if __name__ == '__main__':
@@ -1 +0,0 @@
1
- __version__ = "2.2.1-beta.2"