Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 87 additions & 3 deletions src/main/java/io/percy/selenium/Percy.java
Original file line number Diff line number Diff line change
Expand Up @@ -368,12 +368,96 @@ private boolean isCaptureResponsiveDOM(Map<String, Object> 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&lt;String, Object&gt; (recursively),
* JSONArray -> ArrayList&lt;Object&gt; (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<String, Object> 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<Object> 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<String, Object> deepMerge(Map<String, Object> base, Map<String, Object> override) {
Map<String, Object> result = new HashMap<>();
if (base != null) {
result.putAll(base);
}
if (override == null) {
return result;
}
for (Map.Entry<String, Object> 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<String, Object>) baseValue, (Map<String, Object>) overrideValue));
} else {
result.put(key, overrideValue);
}
}
return result;
}

public JSONObject snapshot(String name, Map<String, Object> 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<String, Object> 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<String, Object> convertedMap = (Map<String, Object>) converted;
baseOptions = convertedMap;
}
}
Map<String, Object> mergedOptions = deepMerge(baseOptions, options);

try {
JavascriptExecutor jse = (JavascriptExecutor) driver;
jse.executeScript(fetchPercyDOM());
Expand All @@ -383,10 +467,10 @@ public JSONObject snapshot(String name, Map<String, Object> 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.
Expand Down
123 changes: 123 additions & 0 deletions src/test/java/io/percy/selenium/SdkTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -1185,6 +1185,129 @@ public void snapshotSurvivesReadinessThrow() throws Exception {
assertEquals("<html></html>", 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<String> scriptCaptor = ArgumentCaptor.forClass(String.class);
when(((JavascriptExecutor) mockedDriver).executeScript(any(String.class)))
.thenReturn(new HashMap<String, Object>());

// Avoid an actual POST back to the CLI.
doReturn(new JSONObject()).when(mockedPercy)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] POST body not asserted

The request(...) stub uses any(JSONObject.class) and never asserts on the posted payload. This SDK intentionally posts the raw per-call options to the CLI (the documented cross-SDK pattern, with config applied server-side), so this is a coverage observation rather than a defect.

Suggestion: Optional — add an ArgumentCaptor<JSONObject> on request(...) if future behavior ever depends on the posted body. Non-blocking.

Reviewer: stack:code-review

.request(eq("/percy/snapshot"), any(JSONObject.class), eq("merge precedence"));

Map<String, Object> options = new HashMap<String, Object>();
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);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] Fragile serialize-arg extraction

The serialized options are recovered via substring/indexOf string slicing of the JS script. This silently breaks if buildSnapshotJS formatting ever changes (e.g. added comment or multiline output).

Suggestion: Optional — capture/verify the serialized map more directly (e.g. spy on the builder) instead of parsing the script string. Non-blocking.

Reviewer: stack:code-review


// 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<String> scriptCaptor = ArgumentCaptor.forClass(String.class);
when(((JavascriptExecutor) mockedDriver).executeScript(any(String.class)))
.thenReturn(new HashMap<String, Object>());

doReturn(new JSONObject()).when(mockedPercy)
.request(eq("/percy/snapshot"), any(JSONObject.class), eq("deep merge"));

Map<String, Object> discoveryOption = new HashMap<String, Object>();
discoveryOption.put("disableCache", true);
Map<String, Object> options = new HashMap<String, Object>();
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);
Expand Down
Loading