diff --git a/percy/snapshot.py b/percy/snapshot.py index 746bd3b..cde71d0 100644 --- a/percy/snapshot.py +++ b/percy/snapshot.py @@ -191,9 +191,54 @@ def _get_origin(url): parsed = urlparse(url) return f"{parsed.scheme}://{parsed.netloc}" +def _wait_for_ready(driver, kwargs): + """Run readiness checks before serialize. PER-7348. + + Sends PercyDOM.waitForReady via execute_async_script. The script checks + typeof PercyDOM.waitForReady in-browser so older CLI versions without the + method are a graceful no-op. Any failure is caught and logged at debug; + serialize still runs. + + Returns readiness diagnostics dict (or None) so callers can attach it + to the domSnapshot for CLI-side logging. + + Readiness config precedence: kwargs['readiness'] > cached + percy.config.snapshot.readiness > {} (CLI falls back to balanced default). + If preset is 'disabled', skip the async script call entirely. + """ + readiness_config = kwargs.get('readiness') + if readiness_config is None: + data = is_percy_enabled() + if isinstance(data, dict): + readiness_config = (data.get('config') or {}).get('snapshot', {}).get('readiness', {}) or {} + else: + readiness_config = {} + if isinstance(readiness_config, dict) and readiness_config.get('preset') == 'disabled': + return None + try: + diagnostics = driver.execute_async_script( + 'var config = ' + json.dumps(readiness_config) + ';' + 'var done = arguments[arguments.length - 1];' + 'try {' + " if (typeof PercyDOM !== 'undefined' && typeof PercyDOM.waitForReady === 'function') {" + ' PercyDOM.waitForReady(config).then(function(r){ done(r); }).catch(function(){ done(); });' + ' } else { done(); }' + '} catch(e) { done(); }' + ) + return diagnostics + except Exception as e: + log(f'waitForReady failed, proceeding to serialize: {e}', 'debug') + return None + + def get_serialized_dom(driver, cookies, percy_dom_script=None, **kwargs): + # 0. Readiness gate before serialize (PER-7348). Graceful on old CLI. + readiness_diagnostics = _wait_for_ready(driver, kwargs) # 1. Serialize the main page first (this adds the data-percy-element-ids) dom_snapshot = driver.execute_script(f'return PercyDOM.serialize({json.dumps(kwargs)})') + # Attach readiness diagnostics so the CLI can log timing and pass/fail + if readiness_diagnostics and isinstance(dom_snapshot, dict): + dom_snapshot['readiness_diagnostics'] = readiness_diagnostics # 2. Process CORS IFrames try: page_origin = _get_origin(driver.current_url) diff --git a/tests/test_snapshot.py b/tests/test_snapshot.py index 9419138..4b5b0d8 100644 --- a/tests/test_snapshot.py +++ b/tests/test_snapshot.py @@ -472,6 +472,72 @@ def test_raise_error_poa_token_with_snapshot(self): " For more information on usage of PercyScreenshot, refer https://www.browserstack.com"\ "/docs/percy/integrate/functional-and-visual", str(context.exception)) + # --- Readiness gate (PER-7348) --------------------------------------- + + def test_readiness_runs_before_serialize_by_default(self): + mock_healthcheck() + mock_snapshot() + + with patch.object(self.driver, 'execute_async_script', wraps=self.driver.execute_async_script) as async_spy, \ + patch.object(self.driver, 'execute_script', wraps=self.driver.execute_script) as sync_spy: + percy_snapshot(self.driver, 'readiness-happy-path') + + async_scripts = [c.args[0] for c in async_spy.call_args_list if c.args] + sync_scripts = [c.args[0] for c in sync_spy.call_args_list if c.args] + # Readiness call made at least once, and contains the typeof guard + waitForReady + self.assertTrue(any('waitForReady' in s and 'typeof PercyDOM' in s for s in async_scripts), + f'expected readiness script via execute_async_script, got: {async_scripts}') + # Serialize call made at least once via sync execute_script + self.assertTrue(any('PercyDOM.serialize' in s for s in sync_scripts), + f'expected serialize via execute_script, got: {sync_scripts}') + + def test_readiness_uses_per_snapshot_config(self): + mock_healthcheck() + mock_snapshot() + + readiness = {'preset': 'strict', 'stabilityWindowMs': 500} + with patch.object(self.driver, 'execute_async_script', wraps=self.driver.execute_async_script) as async_spy: + percy_snapshot(self.driver, 'readiness-config', readiness=readiness) + + scripts = [c.args[0] for c in async_spy.call_args_list if c.args] + readiness_scripts = [s for s in scripts if 'waitForReady' in s] + self.assertTrue(readiness_scripts, 'readiness script should have been sent') + # JSON-serialized config embedded in the script + self.assertIn('"preset": "strict"', readiness_scripts[0]) + self.assertIn('"stabilityWindowMs": 500', readiness_scripts[0]) + + def test_readiness_skipped_when_preset_disabled(self): + mock_healthcheck() + mock_snapshot() + + with patch.object(self.driver, 'execute_async_script', wraps=self.driver.execute_async_script) as async_spy, \ + patch.object(self.driver, 'execute_script', wraps=self.driver.execute_script) as sync_spy: + percy_snapshot(self.driver, 'readiness-disabled', + readiness={'preset': 'disabled'}) + + async_scripts = [c.args[0] for c in async_spy.call_args_list if c.args] + sync_scripts = [c.args[0] for c in sync_spy.call_args_list if c.args] + self.assertFalse(any('waitForReady' in s for s in async_scripts), + f'readiness script should NOT have been sent, got: {async_scripts}') + # Serialize still ran + self.assertTrue(any('PercyDOM.serialize' in s for s in sync_scripts)) + + def test_snapshot_still_posts_when_readiness_raises(self): + mock_healthcheck() + mock_snapshot() + + # Make the readiness call raise; serialize should still run and the + # snapshot POST should still be made. + def explode(*args, **kwargs): + raise RuntimeError('readiness boom') + + with patch.object(self.driver, 'execute_async_script', side_effect=explode): + percy_snapshot(self.driver, 'readiness-boom') + + # Snapshot endpoint was hit + paths = [req.path for req in httpretty.latest_requests()] + self.assertIn('/percy/snapshot', paths) + class TestPercyScreenshot(unittest.TestCase): @classmethod def setUpClass(cls):