diff --git a/src/main/java/io/percy/selenium/Percy.java b/src/main/java/io/percy/selenium/Percy.java index 9ecc344..1ea82a3 100644 --- a/src/main/java/io/percy/selenium/Percy.java +++ b/src/main/java/io/percy/selenium/Percy.java @@ -368,12 +368,96 @@ 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; + // 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"); + 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; jse.executeScript(fetchPercyDOM()); @@ -383,10 +467,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. diff --git a/src/test/java/io/percy/selenium/SdkTest.java b/src/test/java/io/percy/selenium/SdkTest.java index 1f1a0f5..eeda302 100644 --- a/src/test/java/io/percy/selenium/SdkTest.java +++ b/src/test/java/io/percy/selenium/SdkTest.java @@ -1185,6 +1185,129 @@ 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"); + } + + @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);