From f9ed826ba75ef7d676b2c32182176a0940df8a91 Mon Sep 17 00:00:00 2001 From: rishigupta1599 Date: Wed, 6 May 2026 00:49:58 +0530 Subject: [PATCH 1/4] fix: merge .percy.yml config options with snapshot options for serializeDOM Config options from .percy.yml (like widths, minHeight, enableJavaScript, etc.) were not being passed to PercyDOM.serialize(). Only per-snapshot options were used. Now merges both, with per-snapshot options taking priority. Co-Authored-By: Claude Opus 4.6 --- src/main/java/io/percy/selenium/Percy.java | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/main/java/io/percy/selenium/Percy.java b/src/main/java/io/percy/selenium/Percy.java index 453c4bf..c5e7d67 100644 --- a/src/main/java/io/percy/selenium/Percy.java +++ b/src/main/java/io/percy/selenium/Percy.java @@ -353,6 +353,18 @@ public JSONObject snapshot(String name, Map options) { Object domSnapshot = null; + // Merge .percy.yml config options with snapshot options (snapshot options take priority) + Map mergedOptions = new HashMap<>(); + if (cliConfig != null && cliConfig.has("snapshot") && !cliConfig.isNull("snapshot")) { + JSONObject snapshotConfig = cliConfig.getJSONObject("snapshot"); + for (String key : snapshotConfig.keySet()) { + mergedOptions.put(key, snapshotConfig.get(key)); + } + } + if (options != null) { + mergedOptions.putAll(options); + } + try { JavascriptExecutor jse = (JavascriptExecutor) driver; jse.executeScript(fetchPercyDOM()); @@ -362,10 +374,10 @@ public JSONObject snapshot(String name, Map options) { } catch(Exception e) { log("Cookie collection failed " + e.getMessage(), "debug"); } - if (isCaptureResponsiveDOM(options)) { - domSnapshot = captureResponsiveDom(driver, cookies, options); + if (isCaptureResponsiveDOM(mergedOptions)) { + domSnapshot = captureResponsiveDom(driver, cookies, mergedOptions); } else { - domSnapshot = getSerializedDOM(jse, cookies, options); + domSnapshot = getSerializedDOM(jse, cookies, mergedOptions); } } catch (WebDriverException e) { // For some reason, the execution in the browser failed. From fef3e61204e42dc1c96cc0f8d468ca5b3e76ce2e Mon Sep 17 00:00:00 2001 From: rishigupta1599 Date: Tue, 16 Jun 2026 20:14:43 +0530 Subject: [PATCH 2/4] fix: do not let null per-call options clobber .percy.yml config in merge Ref: PER-8053 --- src/main/java/io/percy/selenium/Percy.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/java/io/percy/selenium/Percy.java b/src/main/java/io/percy/selenium/Percy.java index 2164d03..21139ab 100644 --- a/src/main/java/io/percy/selenium/Percy.java +++ b/src/main/java/io/percy/selenium/Percy.java @@ -383,7 +383,14 @@ public JSONObject snapshot(String name, Map options) { } } if (options != null) { - mergedOptions.putAll(options); + // Only overlay non-null per-call options so a null value (set by the + // positional snapshot() overloads for unset params) does not clobber a + // real value coming from .percy.yml config. + for (Map.Entry entry : options.entrySet()) { + if (entry.getValue() != null) { + mergedOptions.put(entry.getKey(), entry.getValue()); + } + } } try { From ede01f6fd69698bc271c406fc6b981dca0056c60 Mon Sep 17 00:00:00 2001 From: rishigupta1599 Date: Tue, 16 Jun 2026 20:29:19 +0530 Subject: [PATCH 3/4] test: cover .percy.yml config <-> per-snapshot merge precedence Ref: PER-8053 --- src/test/java/io/percy/selenium/SdkTest.java | 62 ++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/src/test/java/io/percy/selenium/SdkTest.java b/src/test/java/io/percy/selenium/SdkTest.java index 1f1a0f5..1f9bd90 100644 --- a/src/test/java/io/percy/selenium/SdkTest.java +++ b/src/test/java/io/percy/selenium/SdkTest.java @@ -1185,6 +1185,68 @@ public void snapshotSurvivesReadinessThrow() throws Exception { assertEquals("", result.get("html")); } + @Test + public void snapshotMergesCliConfigWithPerCallOptionsPrecedence() throws Exception { + // .percy.yml config carries a config-only key (enableJavaScript) and a + // percyCSS value that the per-call option should override. + RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); + Percy mockedPercy = spy(new Percy(mockedDriver)); + + setField(mockedPercy, "isPercyEnabled", true); + setField(mockedPercy, "domJs", + "window.PercyDOM = window.PercyDOM || {}; window.PercyDOM.serialize = function(){ return {}; };"); + setField(mockedPercy, "cliConfig", new JSONObject().put("snapshot", + new JSONObject() + .put("enableJavaScript", true) + .put("percyCSS", "FROM_CONFIG"))); + mockedPercy.sessionType = "web"; + + when(mockedDriver.getCurrentUrl()).thenReturn("https://example.com"); + WebDriver.Options mockedOptions = mock(WebDriver.Options.class); + when(mockedDriver.manage()).thenReturn(mockedOptions); + when(mockedOptions.getCookies()).thenReturn(Collections.emptySet()); + when(mockedDriver.findElements(By.tagName("iframe"))).thenReturn(Collections.emptyList()); + + // Capture every script passed to the JavascriptExecutor so we can inspect + // the PercyDOM.serialize(...) payload that getSerializedDOM builds. + ArgumentCaptor scriptCaptor = ArgumentCaptor.forClass(String.class); + when(((JavascriptExecutor) mockedDriver).executeScript(any(String.class))) + .thenReturn(new HashMap()); + + // Avoid an actual POST back to the CLI. + doReturn(new JSONObject()).when(mockedPercy) + .request(eq("/percy/snapshot"), any(JSONObject.class), eq("merge precedence")); + + Map options = new HashMap(); + options.put("percyCSS", "FROM_CALL"); + + mockedPercy.snapshot("merge precedence", options); + + verify((JavascriptExecutor) mockedDriver, atLeastOnce()).executeScript(scriptCaptor.capture()); + + String serializeScript = null; + for (String script : scriptCaptor.getAllValues()) { + if (script != null && script.startsWith("return PercyDOM.serialize(")) { + serializeScript = script; + } + } + assertNotNull(serializeScript, "PercyDOM.serialize script should have been executed"); + + // Extract the JSON argument passed to PercyDOM.serialize(...) and assert + // the merged options reflect config<->per-call precedence. + String jsonArg = serializeScript + .substring(serializeScript.indexOf('(') + 1, serializeScript.lastIndexOf(')')) + .trim(); + JSONObject serialized = new JSONObject(jsonArg); + + // Config-only key survives the merge. + assertTrue(serialized.getBoolean("enableJavaScript"), + "enableJavaScript from .percy.yml config should be present in serialized options"); + // Per-call option wins over the config value. + assertEquals("FROM_CALL", serialized.getString("percyCSS"), + "per-call percyCSS should override the .percy.yml config value"); + } + private static Object invokePrivate(Object target, String methodName, Class[] paramTypes, Object... args) throws Exception { Method method = Percy.class.getDeclaredMethod(methodName, paramTypes); From 449acb433a59acfcee84b13b69c0c70504dc6e14 Mon Sep 17 00:00:00 2001 From: rishigupta1599 Date: Wed, 17 Jun 2026 00:55:04 +0530 Subject: [PATCH 4/4] feat: deep-merge .percy.yml config with per-snapshot options Ref: PER-8053 --- src/main/java/io/percy/selenium/Percy.java | 93 +++++++++++++++++--- src/test/java/io/percy/selenium/SdkTest.java | 61 +++++++++++++ 2 files changed, 140 insertions(+), 14 deletions(-) diff --git a/src/main/java/io/percy/selenium/Percy.java b/src/main/java/io/percy/selenium/Percy.java index 21139ab..1ea82a3 100644 --- a/src/main/java/io/percy/selenium/Percy.java +++ b/src/main/java/io/percy/selenium/Percy.java @@ -368,30 +368,95 @@ private boolean isCaptureResponsiveDOM(Map options) { return (responsiveSnapshotCaptureSDK != null && (boolean) responsiveSnapshotCaptureSDK) || responsiveSnapshotCaptureCLI; } + /** + * Recursively convert a parsed JSON value (from org.json) into plain Java + * collections so it can participate in a generic deep merge. + * + * JSONObject -> HashMap<String, Object> (recursively), + * JSONArray -> ArrayList<Object> (recursively converting elements), + * anything else (scalars, null) is returned as-is. + */ + @SuppressWarnings("unchecked") + private Object jsonToJava(Object value) { + if (value instanceof JSONObject) { + JSONObject obj = (JSONObject) value; + Map map = new HashMap<>(); + for (String key : obj.keySet()) { + Object child = obj.get(key); + if (child == JSONObject.NULL) { + child = null; + } + map.put(key, jsonToJava(child)); + } + return map; + } else if (value instanceof JSONArray) { + JSONArray arr = (JSONArray) value; + List list = new ArrayList<>(); + for (int i = 0; i < arr.length(); i++) { + Object element = arr.get(i); + if (element == JSONObject.NULL) { + element = null; + } + list.add(jsonToJava(element)); + } + return list; + } + return value; + } + + /** + * Generic recursive deep merge of two maps. {@code override} wins over + * {@code base}. Nested maps are merged recursively; arrays and scalars from + * {@code override} replace the corresponding {@code base} value. Null values + * in {@code override} are skipped so they never clobber a real config value. + */ + @SuppressWarnings("unchecked") + private Map deepMerge(Map base, Map override) { + Map result = new HashMap<>(); + if (base != null) { + result.putAll(base); + } + if (override == null) { + return result; + } + for (Map.Entry entry : override.entrySet()) { + String key = entry.getKey(); + Object overrideValue = entry.getValue(); + // Skip null per-call values so they don't clobber config values. + if (overrideValue == null) { + continue; + } + Object baseValue = result.get(key); + if (baseValue instanceof Map && overrideValue instanceof Map) { + result.put(key, deepMerge((Map) baseValue, (Map) overrideValue)); + } else { + result.put(key, overrideValue); + } + } + return result; + } + public JSONObject snapshot(String name, Map options) { if (!isPercyEnabled) { return null; } if ("automate".equals(sessionType)) { throw new RuntimeException("Invalid function call - snapshot(). Please use screenshot() function while using Percy with Automate. For more information on usage of PercyScreenshot, refer https://www.browserstack.com/docs/percy/integrate/functional-and-visual"); } Object domSnapshot = null; - // Merge .percy.yml config options with snapshot options (snapshot options take priority) - Map mergedOptions = new HashMap<>(); + // Deep-merge .percy.yml config options with snapshot options (snapshot + // options take priority). Nested objects merge recursively, per-call + // values win at the leaves, arrays/scalars replace wholesale, and per-call + // null values do NOT clobber a real value coming from .percy.yml config. + Map baseOptions = new HashMap<>(); if (cliConfig != null && cliConfig.has("snapshot") && !cliConfig.isNull("snapshot")) { JSONObject snapshotConfig = cliConfig.getJSONObject("snapshot"); - for (String key : snapshotConfig.keySet()) { - mergedOptions.put(key, snapshotConfig.get(key)); - } - } - if (options != null) { - // Only overlay non-null per-call options so a null value (set by the - // positional snapshot() overloads for unset params) does not clobber a - // real value coming from .percy.yml config. - for (Map.Entry entry : options.entrySet()) { - if (entry.getValue() != null) { - mergedOptions.put(entry.getKey(), entry.getValue()); - } + Object converted = jsonToJava(snapshotConfig); + if (converted instanceof Map) { + @SuppressWarnings("unchecked") + Map convertedMap = (Map) converted; + baseOptions = convertedMap; } } + Map mergedOptions = deepMerge(baseOptions, options); try { JavascriptExecutor jse = (JavascriptExecutor) driver; diff --git a/src/test/java/io/percy/selenium/SdkTest.java b/src/test/java/io/percy/selenium/SdkTest.java index 1f9bd90..eeda302 100644 --- a/src/test/java/io/percy/selenium/SdkTest.java +++ b/src/test/java/io/percy/selenium/SdkTest.java @@ -1247,6 +1247,67 @@ public void snapshotMergesCliConfigWithPerCallOptionsPrecedence() throws Excepti "per-call percyCSS should override the .percy.yml config value"); } + @Test + public void snapshotDeepMergesNestedCliConfigWithPerCallOptions() throws Exception { + // .percy.yml config carries a nested discovery object; the per-call option + // overrides only one nested leaf and must NOT clobber the sibling leaves. + RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); + Percy mockedPercy = spy(new Percy(mockedDriver)); + + setField(mockedPercy, "isPercyEnabled", true); + setField(mockedPercy, "domJs", + "window.PercyDOM = window.PercyDOM || {}; window.PercyDOM.serialize = function(){ return {}; };"); + setField(mockedPercy, "cliConfig", new JSONObject().put("snapshot", + new JSONObject().put("discovery", + new JSONObject() + .put("networkIdleTimeout", 50) + .put("disableCache", false)))); + mockedPercy.sessionType = "web"; + + when(mockedDriver.getCurrentUrl()).thenReturn("https://example.com"); + WebDriver.Options mockedOptions = mock(WebDriver.Options.class); + when(mockedDriver.manage()).thenReturn(mockedOptions); + when(mockedOptions.getCookies()).thenReturn(Collections.emptySet()); + when(mockedDriver.findElements(By.tagName("iframe"))).thenReturn(Collections.emptyList()); + + ArgumentCaptor scriptCaptor = ArgumentCaptor.forClass(String.class); + when(((JavascriptExecutor) mockedDriver).executeScript(any(String.class))) + .thenReturn(new HashMap()); + + doReturn(new JSONObject()).when(mockedPercy) + .request(eq("/percy/snapshot"), any(JSONObject.class), eq("deep merge")); + + Map discoveryOption = new HashMap(); + discoveryOption.put("disableCache", true); + Map options = new HashMap(); + options.put("discovery", discoveryOption); + + mockedPercy.snapshot("deep merge", options); + + verify((JavascriptExecutor) mockedDriver, atLeastOnce()).executeScript(scriptCaptor.capture()); + + String serializeScript = null; + for (String script : scriptCaptor.getAllValues()) { + if (script != null && script.startsWith("return PercyDOM.serialize(")) { + serializeScript = script; + } + } + assertNotNull(serializeScript, "PercyDOM.serialize script should have been executed"); + + String jsonArg = serializeScript + .substring(serializeScript.indexOf('(') + 1, serializeScript.lastIndexOf(')')) + .trim(); + JSONObject serialized = new JSONObject(jsonArg); + + JSONObject discovery = serialized.getJSONObject("discovery"); + // Sibling config leaf is preserved (deep merge, not shallow replace). + assertEquals(50, discovery.getInt("networkIdleTimeout"), + "networkIdleTimeout from .percy.yml config should survive the deep merge"); + // Per-call leaf wins over the config value. + assertTrue(discovery.getBoolean("disableCache"), + "per-call discovery.disableCache should override the .percy.yml config value"); + } + private static Object invokePrivate(Object target, String methodName, Class[] paramTypes, Object... args) throws Exception { Method method = Percy.class.getDeclaredMethod(methodName, paramTypes);