Skip to content

Commit 449acb4

Browse files
feat: deep-merge .percy.yml config with per-snapshot options
Ref: PER-8053
1 parent ede01f6 commit 449acb4

2 files changed

Lines changed: 140 additions & 14 deletions

File tree

src/main/java/io/percy/selenium/Percy.java

Lines changed: 79 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -368,30 +368,95 @@ private boolean isCaptureResponsiveDOM(Map<String, Object> options) {
368368
return (responsiveSnapshotCaptureSDK != null && (boolean) responsiveSnapshotCaptureSDK) || responsiveSnapshotCaptureCLI;
369369
}
370370

371+
/**
372+
* Recursively convert a parsed JSON value (from org.json) into plain Java
373+
* collections so it can participate in a generic deep merge.
374+
*
375+
* JSONObject -> HashMap&lt;String, Object&gt; (recursively),
376+
* JSONArray -> ArrayList&lt;Object&gt; (recursively converting elements),
377+
* anything else (scalars, null) is returned as-is.
378+
*/
379+
@SuppressWarnings("unchecked")
380+
private Object jsonToJava(Object value) {
381+
if (value instanceof JSONObject) {
382+
JSONObject obj = (JSONObject) value;
383+
Map<String, Object> map = new HashMap<>();
384+
for (String key : obj.keySet()) {
385+
Object child = obj.get(key);
386+
if (child == JSONObject.NULL) {
387+
child = null;
388+
}
389+
map.put(key, jsonToJava(child));
390+
}
391+
return map;
392+
} else if (value instanceof JSONArray) {
393+
JSONArray arr = (JSONArray) value;
394+
List<Object> list = new ArrayList<>();
395+
for (int i = 0; i < arr.length(); i++) {
396+
Object element = arr.get(i);
397+
if (element == JSONObject.NULL) {
398+
element = null;
399+
}
400+
list.add(jsonToJava(element));
401+
}
402+
return list;
403+
}
404+
return value;
405+
}
406+
407+
/**
408+
* Generic recursive deep merge of two maps. {@code override} wins over
409+
* {@code base}. Nested maps are merged recursively; arrays and scalars from
410+
* {@code override} replace the corresponding {@code base} value. Null values
411+
* in {@code override} are skipped so they never clobber a real config value.
412+
*/
413+
@SuppressWarnings("unchecked")
414+
private Map<String, Object> deepMerge(Map<String, Object> base, Map<String, Object> override) {
415+
Map<String, Object> result = new HashMap<>();
416+
if (base != null) {
417+
result.putAll(base);
418+
}
419+
if (override == null) {
420+
return result;
421+
}
422+
for (Map.Entry<String, Object> entry : override.entrySet()) {
423+
String key = entry.getKey();
424+
Object overrideValue = entry.getValue();
425+
// Skip null per-call values so they don't clobber config values.
426+
if (overrideValue == null) {
427+
continue;
428+
}
429+
Object baseValue = result.get(key);
430+
if (baseValue instanceof Map && overrideValue instanceof Map) {
431+
result.put(key, deepMerge((Map<String, Object>) baseValue, (Map<String, Object>) overrideValue));
432+
} else {
433+
result.put(key, overrideValue);
434+
}
435+
}
436+
return result;
437+
}
438+
371439
public JSONObject snapshot(String name, Map<String, Object> options) {
372440
if (!isPercyEnabled) { return null; }
373441
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"); }
374442

375443
Object domSnapshot = null;
376444

377-
// Merge .percy.yml config options with snapshot options (snapshot options take priority)
378-
Map<String, Object> mergedOptions = new HashMap<>();
445+
// Deep-merge .percy.yml config options with snapshot options (snapshot
446+
// options take priority). Nested objects merge recursively, per-call
447+
// values win at the leaves, arrays/scalars replace wholesale, and per-call
448+
// null values do NOT clobber a real value coming from .percy.yml config.
449+
Map<String, Object> baseOptions = new HashMap<>();
379450
if (cliConfig != null && cliConfig.has("snapshot") && !cliConfig.isNull("snapshot")) {
380451
JSONObject snapshotConfig = cliConfig.getJSONObject("snapshot");
381-
for (String key : snapshotConfig.keySet()) {
382-
mergedOptions.put(key, snapshotConfig.get(key));
383-
}
384-
}
385-
if (options != null) {
386-
// Only overlay non-null per-call options so a null value (set by the
387-
// positional snapshot() overloads for unset params) does not clobber a
388-
// real value coming from .percy.yml config.
389-
for (Map.Entry<String, Object> entry : options.entrySet()) {
390-
if (entry.getValue() != null) {
391-
mergedOptions.put(entry.getKey(), entry.getValue());
392-
}
452+
Object converted = jsonToJava(snapshotConfig);
453+
if (converted instanceof Map) {
454+
@SuppressWarnings("unchecked")
455+
Map<String, Object> convertedMap = (Map<String, Object>) converted;
456+
baseOptions = convertedMap;
393457
}
394458
}
459+
Map<String, Object> mergedOptions = deepMerge(baseOptions, options);
395460

396461
try {
397462
JavascriptExecutor jse = (JavascriptExecutor) driver;

src/test/java/io/percy/selenium/SdkTest.java

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1247,6 +1247,67 @@ public void snapshotMergesCliConfigWithPerCallOptionsPrecedence() throws Excepti
12471247
"per-call percyCSS should override the .percy.yml config value");
12481248
}
12491249

1250+
@Test
1251+
public void snapshotDeepMergesNestedCliConfigWithPerCallOptions() throws Exception {
1252+
// .percy.yml config carries a nested discovery object; the per-call option
1253+
// overrides only one nested leaf and must NOT clobber the sibling leaves.
1254+
RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class);
1255+
Percy mockedPercy = spy(new Percy(mockedDriver));
1256+
1257+
setField(mockedPercy, "isPercyEnabled", true);
1258+
setField(mockedPercy, "domJs",
1259+
"window.PercyDOM = window.PercyDOM || {}; window.PercyDOM.serialize = function(){ return {}; };");
1260+
setField(mockedPercy, "cliConfig", new JSONObject().put("snapshot",
1261+
new JSONObject().put("discovery",
1262+
new JSONObject()
1263+
.put("networkIdleTimeout", 50)
1264+
.put("disableCache", false))));
1265+
mockedPercy.sessionType = "web";
1266+
1267+
when(mockedDriver.getCurrentUrl()).thenReturn("https://example.com");
1268+
WebDriver.Options mockedOptions = mock(WebDriver.Options.class);
1269+
when(mockedDriver.manage()).thenReturn(mockedOptions);
1270+
when(mockedOptions.getCookies()).thenReturn(Collections.emptySet());
1271+
when(mockedDriver.findElements(By.tagName("iframe"))).thenReturn(Collections.emptyList());
1272+
1273+
ArgumentCaptor<String> scriptCaptor = ArgumentCaptor.forClass(String.class);
1274+
when(((JavascriptExecutor) mockedDriver).executeScript(any(String.class)))
1275+
.thenReturn(new HashMap<String, Object>());
1276+
1277+
doReturn(new JSONObject()).when(mockedPercy)
1278+
.request(eq("/percy/snapshot"), any(JSONObject.class), eq("deep merge"));
1279+
1280+
Map<String, Object> discoveryOption = new HashMap<String, Object>();
1281+
discoveryOption.put("disableCache", true);
1282+
Map<String, Object> options = new HashMap<String, Object>();
1283+
options.put("discovery", discoveryOption);
1284+
1285+
mockedPercy.snapshot("deep merge", options);
1286+
1287+
verify((JavascriptExecutor) mockedDriver, atLeastOnce()).executeScript(scriptCaptor.capture());
1288+
1289+
String serializeScript = null;
1290+
for (String script : scriptCaptor.getAllValues()) {
1291+
if (script != null && script.startsWith("return PercyDOM.serialize(")) {
1292+
serializeScript = script;
1293+
}
1294+
}
1295+
assertNotNull(serializeScript, "PercyDOM.serialize script should have been executed");
1296+
1297+
String jsonArg = serializeScript
1298+
.substring(serializeScript.indexOf('(') + 1, serializeScript.lastIndexOf(')'))
1299+
.trim();
1300+
JSONObject serialized = new JSONObject(jsonArg);
1301+
1302+
JSONObject discovery = serialized.getJSONObject("discovery");
1303+
// Sibling config leaf is preserved (deep merge, not shallow replace).
1304+
assertEquals(50, discovery.getInt("networkIdleTimeout"),
1305+
"networkIdleTimeout from .percy.yml config should survive the deep merge");
1306+
// Per-call leaf wins over the config value.
1307+
assertTrue(discovery.getBoolean("disableCache"),
1308+
"per-call discovery.disableCache should override the .percy.yml config value");
1309+
}
1310+
12501311
private static Object invokePrivate(Object target, String methodName, Class<?>[] paramTypes, Object... args)
12511312
throws Exception {
12521313
Method method = Percy.class.getDeclaredMethod(methodName, paramTypes);

0 commit comments

Comments
 (0)