Skip to content

Commit

Permalink
Added support for responsive snapshot capture
Browse files Browse the repository at this point in the history
  • Loading branch information
chinmay-browserstack committed Sep 27, 2024
1 parent 7762be5 commit 3ffddbb
Show file tree
Hide file tree
Showing 2 changed files with 203 additions and 25 deletions.
213 changes: 188 additions & 25 deletions src/main/java/io/percy/selenium/Percy.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
package io.percy.selenium;

import org.apache.commons.exec.util.StringUtils;
import org.apache.http.Header;
import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
import org.apache.http.util.EntityUtils;
Expand All @@ -14,19 +12,21 @@
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.client.HttpClients;
import org.json.JSONObject;
import org.json.JSONArray;

import java.io.InputStream;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.logging.Level;
import java.util.logging.Logger;

import javax.annotation.Nullable;

import org.openqa.selenium.*;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.remote.*;
import org.openqa.selenium.support.ui.ExpectedCondition;
import org.openqa.selenium.support.ui.WebDriverWait;

import java.lang.reflect.Field;
import java.util.stream.Collectors;

/**
* Percy client for visual testing.
Expand All @@ -39,16 +39,20 @@ public class Percy {
private String domJs = "";

// Maybe get the CLI server address
private String PERCY_SERVER_ADDRESS = System.getenv().getOrDefault("PERCY_SERVER_ADDRESS", "http://localhost:5338");
private static String PERCY_SERVER_ADDRESS = System.getenv().getOrDefault("PERCY_SERVER_ADDRESS", "http://localhost:5338");

// Determine if we're debug logging
private static boolean PERCY_DEBUG = System.getenv().getOrDefault("PERCY_LOGLEVEL", "info").equals("debug");

private static String RESONSIVE_CAPTURE_SLEEP_TIME = System.getenv().getOrDefault("RESONSIVE_CAPTURE_SLEEP_TIME", "");

// for logging
private static String LABEL = "[\u001b[35m" + (PERCY_DEBUG ? "percy:java" : "percy") + "\u001b[39m]";

// Type of session automate/web
protected String sessionType = null;
protected JSONObject eligibleWidths;
private JSONObject CLIconfig;

// Is the Percy server running or not
private boolean isPercyEnabled = healthcheck();
Expand Down Expand Up @@ -143,20 +147,14 @@ public JSONObject snapshot(String name, @Nullable List<Integer> widths, Integer
* @param scope A CSS selector to scope the screenshot to
*/
public JSONObject snapshot(String name, @Nullable List<Integer> widths, Integer minHeight, boolean enableJavaScript, String percyCSS, String scope) {
if (!isPercyEnabled) { return null; }

Map<String, Object> domSnapshot = null;
Map<String, Object> options = new HashMap<String, Object>();
options.put("widths", widths);
options.put("minHeight", minHeight);
options.put("enableJavaScript", enableJavaScript);
options.put("percyCSS", percyCSS);
options.put("scope", scope);

return snapshot(name, options);
return snapshot(name, widths, minHeight, enableJavaScript, percyCSS, scope);
}

public JSONObject snapshot(String name, @Nullable List<Integer> widths, Integer minHeight, boolean enableJavaScript, String percyCSS, String scope, @Nullable Boolean sync) {
return snapshot(name, widths, minHeight, enableJavaScript, percyCSS, scope, sync, null);
}

public JSONObject snapshot(String name, @Nullable List<Integer> widths, Integer minHeight, boolean enableJavaScript, String percyCSS, String scope, @Nullable Boolean sync, Boolean responsiveSnapshotCapture) {
if (!isPercyEnabled) { return null; }

Map<String, Object> domSnapshot = null;
Expand All @@ -167,20 +165,38 @@ public JSONObject snapshot(String name, @Nullable List<Integer> widths, Integer
options.put("percyCSS", percyCSS);
options.put("scope", scope);
options.put("sync", sync);
options.put("responsiveSnapshotCapture", responsiveSnapshotCapture);

return snapshot(name, options);
}

private boolean isCaptureResponsiveDOM(Map<String, Object> options) {
JSONObject percyProperty = CLIconfig.getJSONObject("percy");
if (percyProperty != null && percyProperty.has("deferUploads") && percyProperty.getBoolean("deferUploads")) {
return false;
}

boolean responsiveSnapshotCaptureCLI = CLIconfig.getJSONObject("snapshot").getBoolean("responsiveSnapshotCapture");
Object responsiveSnapshotCaptureSDK = options.get("responsiveSnapshotCapture");

return (responsiveSnapshotCaptureSDK != null && (boolean) responsiveSnapshotCaptureSDK) || responsiveSnapshotCaptureCLI;
}

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://docs.percy.io/docs/integrate-functional-testing-with-visual-testing"); }

Map<String, Object> domSnapshot = null;
List<Map<String, Object>> domSnapshot = new ArrayList<>();

try {
JavascriptExecutor jse = (JavascriptExecutor) driver;
jse.executeScript(fetchPercyDOM());
domSnapshot = (Map<String, Object>) jse.executeScript(buildSnapshotJS(options));
Set<Cookie> cookies = driver.manage().getCookies();
if (isCaptureResponsiveDOM(options)) {
domSnapshot = captureResponsiveDom(driver, cookies, options);
} else {
domSnapshot.add(getSerializedDOM(jse, cookies, options));
}
} catch (WebDriverException e) {
// For some reason, the execution in the browser failed.
if (PERCY_DEBUG) { log(e.getMessage()); }
Expand Down Expand Up @@ -290,12 +306,14 @@ private boolean healthcheck() {
String responseString = EntityUtils.toString(entity, "UTF-8");
JSONObject responseObject = new JSONObject(responseString);
sessionType = (String) responseObject.optString("type", null);
eligibleWidths = responseObject.optJSONObject("widths");
CLIconfig = responseObject.optJSONObject("config");

return true;
} catch (Exception ex) {
log("Percy is not running, disabling snapshots");
// bike shed.. single line?
if (PERCY_DEBUG) { log(ex.toString()); }
log(ex.toString(), "debug");

return false;
}
Expand Down Expand Up @@ -326,7 +344,7 @@ private String fetchPercyDOM() {
return domString;
} catch (Exception ex) {
isPercyEnabled = false;
if (PERCY_DEBUG) { log(ex.toString()); }
log(ex.toString(), "debug");

return "";
}
Expand All @@ -344,7 +362,7 @@ private String fetchPercyDOM() {
* @param percyCSS Percy specific CSS that is only applied in Percy's browsers
*/
private JSONObject postSnapshot(
Map<String, Object> domSnapshot,
List<Map<String, Object>> domSnapshot,
String name,
String url,
Map<String, Object> options
Expand Down Expand Up @@ -390,7 +408,7 @@ protected JSONObject request(String url, JSONObject json, String name) {
return jsonResponse.getJSONObject("data");
}
} catch (Exception ex) {
if (PERCY_DEBUG) { log(ex.toString()); }
log(ex.toString(), "debug");
log("Could not post snapshot " + name);
}
return null;
Expand All @@ -408,6 +426,14 @@ private String buildSnapshotJS(Map<String, Object> options) {
return jsBuilder.toString();
}

private Map<String, Object> getSerializedDOM(JavascriptExecutor jse, Set<Cookie> cookies, Map<String, Object> options) {
Map<String, Object> domSnapshot = (Map<String, Object>) jse.executeScript(buildSnapshotJS(options));
Map<String, Object> mutableSnapshot = new HashMap<>(domSnapshot);
mutableSnapshot.put("cookies", cookies);

return mutableSnapshot;
}

private List<String> getElementIdFromElement(List<RemoteWebElement> elements) {
List<String> ignoredElementsArray = new ArrayList<>();
for (int index = 0; index < elements.size(); index++) {
Expand All @@ -417,7 +443,144 @@ private List<String> getElementIdFromElement(List<RemoteWebElement> elements) {
return ignoredElementsArray;
}

// Get widths for multi DOM
private List<Integer> getWidthsForMultiDom(Map<String, Object> options) {
List<Integer> widths;
if (options.containsKey("widths") && options.get("widths") instanceof List<?>) {
widths = (List<Integer>) options.get("widths");
} else {
widths = new ArrayList<>();
}
// Create a Set to avoid duplicates
Set<Integer> allWidths = new HashSet<>();

JSONArray mobileWidths = eligibleWidths.getJSONArray("mobile");
for (int i = 0; i < mobileWidths.length(); i++) {
allWidths.add(mobileWidths.getInt(i));
}

// Add input widths if provided
if (widths.size() != 0) {
for (int width : widths) {
allWidths.add(width);
}
} else {
// Add config widths if no input widths are provided
JSONArray configWidths = eligibleWidths.getJSONArray("config");
for (int i = 0; i < configWidths.length(); i++) {
allWidths.add(configWidths.getInt(i));
}
}

// Convert Set back to List
return allWidths.stream().collect(Collectors.toList());
}

// Method to check if ChromeDriver supports CDP by checking the existence of executeCdpCommand
private static boolean isCdpSupported(ChromeDriver chromeDriver) {
try {
chromeDriver.getClass().getMethod("executeCdpCommand", String.class, Map.class);
return true;
} catch (NoSuchMethodException e) {
return false;
}
}

// Change window dimensions and wait for the resize event
private static void changeWindowDimensionAndWait(WebDriver driver, int width, int height, int resizeCount) {
try {
if (driver instanceof ChromeDriver && isCdpSupported((ChromeDriver) driver)) {
Map<String, Object> commandParams = new HashMap<>();
commandParams.put("width", width);
commandParams.put("height", height);
commandParams.put("deviceScaleFactor", 1);
commandParams.put("mobile", false);

((ChromeDriver) driver).executeCdpCommand("Emulation.setDeviceMetricsOverride", commandParams);
} else {
driver.manage().window().setSize(new Dimension(width, height));
}
} catch (Exception e) {
log("Resizing using CDP failed, falling back to driver for width " + width + ": " + e.getMessage(), "debug");
driver.manage().window().setSize(new Dimension(width, height));
}

// Wait for window resize event using WebDriverWait
try {
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(1));
wait.until((ExpectedCondition<Boolean>) d ->
(long) ((JavascriptExecutor) d).executeScript("return window.resizeCount") == resizeCount);
} catch (WebDriverException e) {
log("Timed out waiting for window resize event for width " + width, "debug");
}
}

// Capture responsive DOM for different widths
public List<Map<String, Object>> captureResponsiveDom(WebDriver driver, Set<Cookie> cookies, Map<String, Object> options) {
List<Integer> widths = getWidthsForMultiDom(options);

List<Map<String, Object>> domSnapshots = new ArrayList<>();

Dimension windowSize = driver.manage().window().getSize();
int currentWidth = windowSize.getWidth();
int currentHeight = windowSize.getHeight();
int lastWindowWidth = currentWidth;
int resizeCount = 0;
JavascriptExecutor jse = (JavascriptExecutor) driver;

// Inject JS to count window resize events
jse.executeScript("PercyDOM.waitForResize()");

for (int width : widths) {
if (lastWindowWidth != width) {
resizeCount++;
changeWindowDimensionAndWait(driver, width, currentHeight, resizeCount);
lastWindowWidth = width;
}

try {
int sleepTime = Integer.parseInt(RESONSIVE_CAPTURE_SLEEP_TIME);
Thread.sleep(sleepTime * 1000); // Sleep if needed
} catch (InterruptedException | NumberFormatException ignored) {
}
Map<String, Object> domSnapshot = getSerializedDOM(jse, cookies, options);
domSnapshot.put("width", width);
domSnapshots.add(domSnapshot);
}

// Revert to the original window size
changeWindowDimensionAndWait(driver, currentWidth, currentHeight, resizeCount + 1);

return domSnapshots;
}

protected static void log(String message) {
System.out.println(LABEL + " " + message);
log(message, "info");
}

protected static void log(String message, String level) {
message = LABEL + " " + message;
String logJsonString = "{\"message\": \"" + message + "\", \"level\": \"" + level + "\"}";
StringEntity entity = new StringEntity(logJsonString, ContentType.APPLICATION_JSON);
int timeout = 1000; // 1 second

// Create RequestConfig with timeout
RequestConfig requestConfig = RequestConfig.custom()
.setSocketTimeout(timeout)
.setConnectTimeout(timeout)
.build();

try (CloseableHttpClient httpClient = HttpClients.custom().setDefaultRequestConfig(requestConfig).build()) {
HttpPost request = new HttpPost(PERCY_SERVER_ADDRESS + "/percy/log");
request.setEntity(entity);
httpClient.execute(request);
} catch (Exception ex) {
if (PERCY_DEBUG) { System.out.println("Sending log to CLI Failed " + ex.toString()); }
} finally {
// Only log if level is not 'debug' or PERCY_DEBUG is true
if (!"debug".equals(level) || PERCY_DEBUG) {
System.out.println(message);
}
}
}
}
15 changes: 15 additions & 0 deletions src/test/java/io/percy/selenium/SdkTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,21 @@ public void takeSnapshotWithSyncCLI(){
assertEquals(data.get("screenshots").getClass().isAssignableFrom(JSONArray.class), true);
}

@Test
public void snapshotWithResponsiveSnapshotCapture() {
// To run via test via chrome CDP uncomment below lines and replace chromedriver path
// System.setProperty("webdriver.chrome.driver", "<chromedriver_path>");
// ChromeOptions chromeOptions = new ChromeOptions();
// chromeOptions.addArguments("--remote-allow-origins=*");
// driver = new ChromeDriver(chromeOptions);

driver.get("https://www.webfx.com/tools/whats-my-browser-size/");
Map<String, Object> options = new HashMap<String, Object>();
options.put("widths", Arrays.asList(768, 992, 1200));
options.put("responsiveSnapshotCapture", true);
percy.snapshot("Site with snapshotWithResponsiveSnapshotCapture", options);
}

@Test
public void takeScreenshot() {
RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class);
Expand Down

0 comments on commit 3ffddbb

Please sign in to comment.