(), options);
+ assertEquals(1, snapshots.size());
+ } finally {
+ setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", original);
+ setStaticField(Percy.class, "RESPONSIVE_CAPTURE_SLEEP_TIME", originalSleep);
+ server.stop(0);
+ }
+ }
+
+ // ------------------------------------------------------------------
+ // helpers (mirrors PercyLogicTest / SdkTest reflection helpers)
+ // ------------------------------------------------------------------
+
+ private HttpServer startWidthsConfigServer(final String body, final int status) throws IOException {
+ HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);
+ server.createContext("/percy/widths-config", new HttpHandler() {
+ @Override
+ public void handle(HttpExchange exchange) throws IOException {
+ byte[] payload = body.getBytes("UTF-8");
+ exchange.sendResponseHeaders(status, payload.length);
+ try (OutputStream os = exchange.getResponseBody()) { os.write(payload); }
+ }
+ });
+ server.start();
+ return server;
+ }
+
+ private static Object invokePrivate(Object target, String methodName, Class>[] paramTypes, Object... args)
+ throws Exception {
+ Method method = Percy.class.getDeclaredMethod(methodName, paramTypes);
+ method.setAccessible(true);
+ return method.invoke(target, args);
+ }
+
+ private static void setField(Object target, String fieldName, Object value) throws Exception {
+ Field field = Percy.class.getDeclaredField(fieldName);
+ field.setAccessible(true);
+ field.set(target, value);
+ }
+
+ private static boolean getBooleanField(Object target, String fieldName) throws Exception {
+ Field field = Percy.class.getDeclaredField(fieldName);
+ field.setAccessible(true);
+ return (boolean) field.get(target);
+ }
+
+ private static void setStaticField(Class> clazz, String fieldName, Object value) throws Exception {
+ Field field = clazz.getDeclaredField(fieldName);
+ field.setAccessible(true);
+ field.set(null, value);
+ }
+
+ private static String getStaticStringField(Class> clazz, String fieldName) throws Exception {
+ Field field = clazz.getDeclaredField(fieldName);
+ field.setAccessible(true);
+ return (String) field.get(null);
+ }
+}
diff --git a/src/test/java/io/percy/selenium/PercyLogicTest.java b/src/test/java/io/percy/selenium/PercyLogicTest.java
new file mode 100644
index 0000000..d52f32d
--- /dev/null
+++ b/src/test/java/io/percy/selenium/PercyLogicTest.java
@@ -0,0 +1,1045 @@
+package io.percy.selenium;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.net.HttpURLConnection;
+import java.net.InetSocketAddress;
+import java.net.URL;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.http.HttpResponse;
+import org.apache.http.client.config.RequestConfig;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClients;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+import org.openqa.selenium.By;
+import org.openqa.selenium.Cookie;
+import org.openqa.selenium.Dimension;
+import org.openqa.selenium.JavascriptExecutor;
+import org.openqa.selenium.WebDriver;
+import org.openqa.selenium.WebElement;
+import org.openqa.selenium.WebDriver.TargetLocator;
+import org.openqa.selenium.WrapsDriver;
+
+import org.openqa.selenium.remote.*;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpHandler;
+import com.sun.net.httpserver.HttpServer;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import org.mockito.ArgumentCaptor;
+import static org.mockito.Mockito.*;
+
+/**
+ * Non-driver logic tests for {@link Percy}, {@link Environment}, {@link DriverMetadata}
+ * and {@link Cache}. Unlike {@code SdkTest} these never instantiate a live
+ * {@code FirefoxDriver}; every WebDriver interaction is mocked and HTTP is served
+ * by an in-process {@link HttpServer}, so the whole class runs locally and on CI.
+ *
+ * Mirrors the mock / reflection style used in {@code SdkTest}: {@code spy(new Percy(...))},
+ * {@code setField} / {@code setStaticField} to seed private state, and {@code invokePrivate}
+ * to exercise package-private helpers.
+ */
+public class PercyLogicTest {
+
+ // ------------------------------------------------------------------
+ // createRegion
+ // ------------------------------------------------------------------
+
+ @Test
+ public void createRegionStandardIncludesConfigurationAndAssertion() {
+ Percy percy = spy(new Percy(mock(RemoteWebDriver.class)));
+ Map params = new HashMap();
+ params.put("algorithm", "standard");
+ params.put("imageIgnoreThreshold", 0.2);
+ params.put("bannersEnabled", false);
+ params.put("adsEnabled", true);
+ params.put("diffIgnoreThreshold", 0.1);
+
+ Map region = percy.createRegion(params);
+
+ @SuppressWarnings("unchecked")
+ Map configuration = (Map) region.get("configuration");
+ assertNotNull(configuration);
+ assertEquals(0.2, configuration.get("imageIgnoreThreshold"));
+ assertFalse((Boolean) configuration.get("bannersEnabled"));
+ assertTrue((Boolean) configuration.get("adsEnabled"));
+
+ @SuppressWarnings("unchecked")
+ Map assertion = (Map) region.get("assertion");
+ assertNotNull(assertion);
+ assertEquals(0.1, assertion.get("diffIgnoreThreshold"));
+ }
+
+ @Test
+ public void createRegionDefaultsAlgorithmToIgnoreAndOmitsOptionalSections() {
+ Percy percy = spy(new Percy(mock(RemoteWebDriver.class)));
+ Map params = new HashMap();
+ params.put("elementCSS", ".thing");
+
+ Map region = percy.createRegion(params);
+
+ assertEquals("ignore", region.get("algorithm"));
+ @SuppressWarnings("unchecked")
+ Map elementSelector = (Map) region.get("elementSelector");
+ assertEquals(".thing", elementSelector.get("elementCSS"));
+ assertFalse(region.containsKey("configuration"));
+ assertFalse(region.containsKey("assertion"));
+ assertFalse(region.containsKey("padding"));
+ }
+
+ @Test
+ public void createRegionWithBoundingBoxAndPadding() {
+ Percy percy = spy(new Percy(mock(RemoteWebDriver.class)));
+ Map params = new HashMap();
+ params.put("boundingBox", "1,2,3,4");
+ params.put("padding", 7);
+
+ Map region = percy.createRegion(params);
+
+ @SuppressWarnings("unchecked")
+ Map elementSelector = (Map) region.get("elementSelector");
+ assertEquals("1,2,3,4", elementSelector.get("boundingBox"));
+ assertEquals(7, region.get("padding"));
+ }
+
+ // ------------------------------------------------------------------
+ // snapshot / screenshot dispatch
+ // ------------------------------------------------------------------
+
+ @Test
+ public void snapshotReturnsNullWhenPercyDisabled() throws Exception {
+ Percy percy = spy(new Percy(mock(RemoteWebDriver.class)));
+ // Force the disabled state so the assertion holds regardless of whether a
+ // live Percy CLI is running (e.g. under `percy exec` on CI).
+ setField(percy, "isPercyEnabled", false);
+ assertNull(percy.snapshot("disabled"));
+ assertNull(percy.snapshot("disabled", Arrays.asList(800)));
+ assertNull(percy.snapshot("disabled", Arrays.asList(800), 600));
+ assertNull(percy.snapshot("disabled", new HashMap()));
+ }
+
+ @Test
+ public void screenshotReturnsNullWhenPercyDisabled() throws Exception {
+ Percy percy = spy(new Percy(mock(RemoteWebDriver.class)));
+ // Force the disabled state so the assertion holds regardless of whether a
+ // live Percy CLI is running (e.g. under `percy exec` on CI).
+ setField(percy, "isPercyEnabled", false);
+ assertNull(percy.screenshot("disabled"));
+ }
+
+ @Test
+ public void snapshotPostsDomForWebSession() throws Exception {
+ RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class);
+ Percy mockedPercy = spy(new Percy(mockedDriver));
+ setField(mockedPercy, "isPercyEnabled", true);
+ setField(mockedPercy, "domJs", "window.PercyDOM = window.PercyDOM || {};");
+ setField(mockedPercy, "cliConfig", new JSONObject().put("snapshot", new JSONObject()));
+ 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());
+ when(((JavascriptExecutor) mockedDriver).executeScript(anyString())).thenReturn(new HashMap());
+
+ JSONObject mockedResponse = new JSONObject().put("name", "web_snap");
+ doReturn(mockedResponse).when(mockedPercy).request(eq("/percy/snapshot"), any(JSONObject.class), eq("web_snap"));
+
+ JSONObject data = mockedPercy.snapshot("web_snap");
+ assertEquals("web_snap", data.getString("name"));
+ verify(mockedPercy).request(eq("/percy/snapshot"), any(JSONObject.class), eq("web_snap"));
+ }
+
+ @Test
+ public void snapshotThrowsForAutomateSession() throws Exception {
+ Percy mockedPercy = spy(new Percy(mock(RemoteWebDriver.class)));
+ setField(mockedPercy, "isPercyEnabled", true);
+ mockedPercy.sessionType = "automate";
+
+ Throwable exception = assertThrows(RuntimeException.class, () -> mockedPercy.snapshot("x"));
+ assertTrue(exception.getMessage().contains("Invalid function call - snapshot()"));
+ }
+
+ @Test
+ public void screenshotThrowsForWebSession() throws Exception {
+ Percy mockedPercy = spy(new Percy(mock(RemoteWebDriver.class)));
+ setField(mockedPercy, "isPercyEnabled", true);
+
+ Throwable exception = assertThrows(RuntimeException.class, () -> mockedPercy.screenshot("x"));
+ assertTrue(exception.getMessage().contains("Invalid function call - screenshot()"));
+ }
+
+ @Test
+ public void screenshotSendsSessionMetadataAndCapabilities() throws Exception {
+ RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class);
+ HttpCommandExecutor commandExecutor = mock(HttpCommandExecutor.class);
+ when(commandExecutor.getAddressOfRemoteServer()).thenReturn(new URL("https://hub-cloud.browserstack.com/wd/hub"));
+
+ Percy mockedPercy = spy(new Percy(mockedDriver));
+ setField(mockedPercy, "isPercyEnabled", true);
+ mockedPercy.sessionType = "automate";
+
+ when(mockedDriver.getSessionId()).thenReturn(new SessionId("session-789"));
+ when(mockedDriver.getCommandExecutor()).thenReturn(commandExecutor);
+ DesiredCapabilities capabilities = new DesiredCapabilities();
+ capabilities.setCapability("browserName", "Chrome");
+ capabilities.setCapability("platformName", "Windows");
+ when(mockedDriver.getCapabilities()).thenReturn(capabilities);
+
+ Cache.CACHE_MAP.clear();
+ ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(JSONObject.class);
+ doReturn(new JSONObject()).when(mockedPercy).request(eq("/percy/automateScreenshot"), bodyCaptor.capture(), eq("Automate Snap"));
+
+ mockedPercy.screenshot("Automate Snap");
+
+ JSONObject body = bodyCaptor.getValue();
+ assertEquals("session-789", body.getString("sessionId"));
+ assertEquals("https://hub-cloud.browserstack.com/wd/hub", body.getString("commandExecutorUrl"));
+ assertEquals("Automate Snap", body.getString("snapshotName"));
+ assertTrue(body.getString("clientInfo").startsWith("percy-java-selenium/"));
+ assertEquals("Chrome", body.getJSONObject("capabilities").getString("browserName"));
+ }
+
+ @Test
+ public void screenshotConvertsSnakeCaseRegionElementsToIds() throws Exception {
+ RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class);
+ HttpCommandExecutor commandExecutor = mock(HttpCommandExecutor.class);
+ when(commandExecutor.getAddressOfRemoteServer()).thenReturn(new URL("https://hub-cloud.browserstack.com/wd/hub"));
+
+ Percy mockedPercy = spy(new Percy(mockedDriver));
+ setField(mockedPercy, "isPercyEnabled", true);
+ mockedPercy.sessionType = "automate";
+
+ when(mockedDriver.getSessionId()).thenReturn(new SessionId("123"));
+ when(mockedDriver.getCommandExecutor()).thenReturn(commandExecutor);
+ when(mockedDriver.getCapabilities()).thenReturn(new DesiredCapabilities());
+ Cache.CACHE_MAP.clear();
+
+ RemoteWebElement ignoreEl = mock(RemoteWebElement.class);
+ RemoteWebElement considerEl = mock(RemoteWebElement.class);
+ when(ignoreEl.getId()).thenReturn("ig-1");
+ when(considerEl.getId()).thenReturn("co-2");
+
+ Map options = new HashMap();
+ options.put("ignore_region_selenium_elements", Arrays.asList(ignoreEl));
+ options.put("consider_region_selenium_elements", Arrays.asList(considerEl));
+
+ ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(JSONObject.class);
+ doReturn(new JSONObject()).when(mockedPercy).request(eq("/percy/automateScreenshot"), bodyCaptor.capture(), eq("Regions"));
+
+ mockedPercy.screenshot("Regions", options);
+
+ JSONObject capturedOptions = bodyCaptor.getValue().getJSONObject("options");
+ assertEquals("ig-1", capturedOptions.getJSONArray("ignore_region_elements").getString(0));
+ assertEquals("co-2", capturedOptions.getJSONArray("consider_region_elements").getString(0));
+ assertFalse(capturedOptions.has("ignore_region_selenium_elements"));
+ assertFalse(capturedOptions.has("consider_region_selenium_elements"));
+ }
+
+ // ------------------------------------------------------------------
+ // isCaptureResponsiveDOM
+ // ------------------------------------------------------------------
+
+ @Test
+ public void isCaptureResponsiveDomTrueWhenCliConfigEnablesIt() throws Exception {
+ Percy mockedPercy = spy(new Percy(mock(RemoteWebDriver.class)));
+ setField(mockedPercy, "eligibleWidths", new JSONObject().put("default", 1280));
+ setField(mockedPercy, "cliConfig",
+ new JSONObject().put("snapshot", new JSONObject().put("responsiveSnapshotCapture", true)));
+
+ boolean result = (boolean) invokePrivate(
+ mockedPercy, "isCaptureResponsiveDOM", new Class[]{Map.class}, new HashMap());
+ assertTrue(result);
+ }
+
+ @Test
+ public void isCaptureResponsiveDomFalseWhenNeitherSdkNorCliEnableIt() throws Exception {
+ Percy mockedPercy = spy(new Percy(mock(RemoteWebDriver.class)));
+ setField(mockedPercy, "eligibleWidths", new JSONObject().put("default", 1280));
+ setField(mockedPercy, "cliConfig",
+ new JSONObject().put("snapshot", new JSONObject()));
+
+ boolean result = (boolean) invokePrivate(
+ mockedPercy, "isCaptureResponsiveDOM", new Class[]{Map.class}, new HashMap());
+ assertFalse(result);
+ }
+
+ // ------------------------------------------------------------------
+ // width-config HTTP helpers
+ // ------------------------------------------------------------------
+
+ @Test
+ public void buildWidthsQueryParamJoinsAndHandlesEmpty() throws Exception {
+ Percy mockedPercy = spy(new Percy(mock(RemoteWebDriver.class)));
+
+ assertEquals("?widths=320,640",
+ invokePrivate(mockedPercy, "buildWidthsQueryParam", new Class[]{List.class}, Arrays.asList(320, 640)));
+ assertEquals("",
+ invokePrivate(mockedPercy, "buildWidthsQueryParam", new Class[]{List.class}, new Object[]{null}));
+ assertEquals("",
+ invokePrivate(mockedPercy, "buildWidthsQueryParam", new Class[]{List.class}, Collections.emptyList()));
+ }
+
+ @Test
+ public void getResponsiveWidthsParsesWidthsAndOptionalHeights() throws Exception {
+ HttpServer server = startWidthsConfigServer(
+ "{\"widths\":[{\"width\":375},{\"width\":1280,\"height\":900}]}", HttpURLConnection.HTTP_OK);
+ String originalAddress = getStaticStringField(Percy.class, "PERCY_SERVER_ADDRESS");
+ try {
+ setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", "http://localhost:" + server.getAddress().getPort());
+ Percy mockedPercy = spy(new Percy(mock(RemoteWebDriver.class)));
+
+ @SuppressWarnings("unchecked")
+ List