@@ -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<String, Object> (recursively),
376+ * JSONArray -> ArrayList<Object> (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 ;
0 commit comments