diff --git a/.gitignore b/.gitignore index 7f28457..7335f4e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,11 @@ node_modules/ .vscode .settings .DS_Store + +# bstack-ai-harness:begin (managed — do not edit between markers) +bstack-ai-harness.yml +.harness-docs.json +.harness-manifest.json +CLAUDE.md +.claude/ +# bstack-ai-harness:end diff --git a/pom.xml b/pom.xml index 0777bba..58ebe44 100644 --- a/pom.xml +++ b/pom.xml @@ -184,6 +184,58 @@ false + + org.jacoco + jacoco-maven-plugin + 0.8.12 + + + prepare-agent + + prepare-agent + + + + report + test + + report + + + + check + test + + check + + + + + BUNDLE + + + LINE + COVEREDRATIO + + 0.94 + + + + + + + + maven-jar-plugin 3.3.0 diff --git a/src/main/java/io/percy/selenium/cucumber/PercySteps.java b/src/main/java/io/percy/selenium/cucumber/PercySteps.java index 3811b33..ce396e3 100644 --- a/src/main/java/io/percy/selenium/cucumber/PercySteps.java +++ b/src/main/java/io/percy/selenium/cucumber/PercySteps.java @@ -85,9 +85,25 @@ public static void setDriver(WebDriver webDriver) { } private static String getCucumberVersion() { - try { + // The version lookup is delegated to resolveCucumberVersion so the + // null / throwing fallbacks can be exercised deterministically in tests + // (the cucumber jar manifest is absent under test). Behavior is identical + // to reading io.cucumber.java.en.Given's implementation version inline. + return resolveCucumberVersion(() -> { Package pkg = io.cucumber.java.en.Given.class.getPackage(); - String version = pkg != null ? pkg.getImplementationVersion() : null; + return pkg != null ? pkg.getImplementationVersion() : null; + }); + } + + /** + * Resolves the cucumber version using the supplied {@code resolver}, + * falling back to {@code "unknown"} when the resolver returns null or + * throws. Package-private seam so the fallback branches are testable + * without a manifest; not part of the public API. + */ + static String resolveCucumberVersion(java.util.concurrent.Callable resolver) { + try { + String version = resolver.call(); return version != null ? version : "unknown"; } catch (Exception e) { return "unknown"; diff --git a/src/test/java/io/percy/selenium/CacheTest.java b/src/test/java/io/percy/selenium/CacheTest.java index 2c377a3..5c74b3d 100644 --- a/src/test/java/io/percy/selenium/CacheTest.java +++ b/src/test/java/io/percy/selenium/CacheTest.java @@ -13,6 +13,7 @@ import static org.mockito.Mockito.*; import java.net.URL; import java.util.concurrent.ConcurrentHashMap; +import java.lang.reflect.Field; public class CacheTest { private static RemoteWebDriver mockedDriver; @@ -58,4 +59,70 @@ public void testCommandExecutorUrl() { String commandExecutorUrl = driverMetadata.getCommandExecutorUrl(); assertEquals(Cache.CACHE_MAP.get(key), commandExecutorUrl); } + + @Test + public void testCacheInstantiable() { + // Exercises the implicit default constructor of Cache (its only line). + assertNotNull(new Cache()); + } + + // ------------------------------------------------------------------ + // getCommandExecutorUrl: TracedCommandExecutor unwrap branch. + // + // When the executor's class name contains "TracedCommandExecutor", + // DriverMetadata reflectively reads its private `delegate` field and + // unwraps to the underlying HttpCommandExecutor. These fixtures let us + // drive both the successful unwrap and the reflective-failure fallback + // without a live Selenium tracing executor. + // ------------------------------------------------------------------ + + /** Mirrors Selenium's internal wrapper: a delegate field holding the real executor. */ + static class TracedCommandExecutor implements CommandExecutor { + @SuppressWarnings("unused") + private final CommandExecutor delegate; + TracedCommandExecutor(CommandExecutor delegate) { this.delegate = delegate; } + @Override + public org.openqa.selenium.remote.Response execute(org.openqa.selenium.remote.Command command) { + throw new UnsupportedOperationException(); + } + } + + /** Same name suffix but without a `delegate` field, to drive the catch fallback. */ + static class BrokenTracedCommandExecutor implements CommandExecutor { + @Override + public org.openqa.selenium.remote.Response execute(org.openqa.selenium.remote.Command command) { + throw new UnsupportedOperationException(); + } + } + + @Test + public void testCommandExecutorUrlUnwrapsTracedExecutor() throws Exception { + Cache.CACHE_MAP.clear(); + RemoteWebDriver driver = mock(RemoteWebDriver.class); + when(driver.getSessionId()).thenReturn(new SessionId("traced-1")); + + HttpCommandExecutor inner = mock(HttpCommandExecutor.class); + when(inner.getAddressOfRemoteServer()).thenReturn(new URL("https://hub.example.com/wd/hub")); + TracedCommandExecutor traced = new TracedCommandExecutor(inner); + when(driver.getCommandExecutor()).thenReturn(traced); + + DriverMetadata driverMetadata = new DriverMetadata(driver); + String url = driverMetadata.getCommandExecutorUrl(); + assertEquals("https://hub.example.com/wd/hub", url); + } + + @Test + public void testCommandExecutorUrlReturnsErrorWhenDelegateFieldMissing() { + Cache.CACHE_MAP.clear(); + RemoteWebDriver driver = mock(RemoteWebDriver.class); + when(driver.getSessionId()).thenReturn(new SessionId("traced-2")); + when(driver.getCommandExecutor()).thenReturn(new BrokenTracedCommandExecutor()); + + DriverMetadata driverMetadata = new DriverMetadata(driver); + // No `delegate` field -> reflective lookup throws and the catch returns + // the exception's string form rather than a URL. + String result = driverMetadata.getCommandExecutorUrl(); + assertNotNull(result); + assertTrue(result.contains("NoSuchFieldException") || result.contains("delegate")); + } } diff --git a/src/test/java/io/percy/selenium/PercyDriverPathTest.java b/src/test/java/io/percy/selenium/PercyDriverPathTest.java new file mode 100644 index 0000000..814d41a --- /dev/null +++ b/src/test/java/io/percy/selenium/PercyDriverPathTest.java @@ -0,0 +1,787 @@ +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.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.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.WebDriverException; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.WebDriver.TargetLocator; +import org.openqa.selenium.WebDriver.Timeouts; +import org.openqa.selenium.chrome.ChromeDriver; + +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.*; +import java.time.Duration; + +/** + * Mock-based tests for the WebDriver / browser-bound paths of {@link Percy} + * that {@code SdkTest} normally exercises against a live FirefoxDriver. Every + * driver, JavascriptExecutor and HTTP interaction here is mocked or served by an + * in-process {@link HttpServer}, so the class runs locally and on CI without a + * browser. Pairs with {@code PercyLogicTest}; together they cover 100% of the + * non-{@code SdkTest} reachable lines. + */ +public class PercyDriverPathTest { + + // ------------------------------------------------------------------ + // snapshot(name, options) full flow: cookie failure, responsive branch, + // WebDriverException / generic Exception catches. + // ------------------------------------------------------------------ + + @Test + public void snapshotSwallowsCookieCollectionFailureAndStillPosts() throws Exception { + RemoteWebDriver driver = mock(RemoteWebDriver.class); + Percy percy = spy(new Percy(driver)); + setField(percy, "isPercyEnabled", true); + setField(percy, "domJs", "window.PercyDOM = window.PercyDOM || {};"); + setField(percy, "cliConfig", new JSONObject().put("snapshot", new JSONObject())); + percy.sessionType = "web"; + + WebDriver.Options options = mock(WebDriver.Options.class); + when(driver.manage()).thenReturn(options); + // getCookies throws -> the catch on line ~383-384 logs and continues. + when(options.getCookies()).thenThrow(new WebDriverException("no cookies")); + when(driver.getCurrentUrl()).thenReturn("https://example.com"); + when(driver.findElements(By.tagName("iframe"))).thenReturn(Collections.emptyList()); + when(((JavascriptExecutor) driver).executeScript(anyString())).thenReturn(new HashMap()); + + JSONObject response = new JSONObject().put("name", "cookie_fail"); + doReturn(response).when(percy).request(eq("/percy/snapshot"), any(JSONObject.class), eq("cookie_fail")); + + JSONObject data = percy.snapshot("cookie_fail"); + assertEquals("cookie_fail", data.getString("name")); + } + + @Test + public void snapshotUsesResponsiveCaptureWhenEnabled() throws Exception { + RemoteWebDriver driver = mock(RemoteWebDriver.class); + Percy percy = spy(new Percy(driver)); + setField(percy, "isPercyEnabled", true); + setField(percy, "domJs", "window.PercyDOM = window.PercyDOM || {};"); + setField(percy, "eligibleWidths", new JSONObject().put("default", 1280)); + setField(percy, "cliConfig", + new JSONObject().put("snapshot", new JSONObject().put("responsiveSnapshotCapture", true))); + percy.sessionType = "web"; + + WebDriver.Options options = mock(WebDriver.Options.class); + when(driver.manage()).thenReturn(options); + when(options.getCookies()).thenReturn(Collections.emptySet()); + when(driver.getCurrentUrl()).thenReturn("https://example.com"); + + // captureResponsiveDom is browser-bound; stub it so we just verify the + // dispatch into the responsive branch (line ~387) without driving resizes. + List> fakeResponsive = Collections.singletonList(new HashMap()); + doReturn(fakeResponsive).when(percy).captureResponsiveDom(eq(driver), anySet(), anyMap()); + + JSONObject response = new JSONObject().put("name", "responsive"); + doReturn(response).when(percy).request(eq("/percy/snapshot"), any(JSONObject.class), eq("responsive")); + + JSONObject data = percy.snapshot("responsive"); + assertEquals("responsive", data.getString("name")); + verify(percy).captureResponsiveDom(eq(driver), anySet(), anyMap()); + } + + @Test + public void snapshotSwallowsWebDriverExceptionFromExecutor() throws Exception { + RemoteWebDriver driver = mock(RemoteWebDriver.class); + Percy percy = spy(new Percy(driver)); + setField(percy, "isPercyEnabled", true); + setField(percy, "domJs", "window.PercyDOM = window.PercyDOM || {};"); + setField(percy, "cliConfig", new JSONObject().put("snapshot", new JSONObject())); + percy.sessionType = "web"; + + // executeScript throws a WebDriverException -> the WebDriverException + // catch (line ~391) logs at debug; domSnapshot stays null but we still POST. + when(((JavascriptExecutor) driver).executeScript(anyString())) + .thenThrow(new WebDriverException("script blew up")); + when(driver.getCurrentUrl()).thenReturn("https://example.com"); + + ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(JSONObject.class); + doReturn(new JSONObject().put("name", "wde")).when(percy) + .request(eq("/percy/snapshot"), bodyCaptor.capture(), eq("wde")); + + JSONObject data = percy.snapshot("wde"); + assertEquals("wde", data.getString("name")); + // domSnapshot was never produced -> posted as JSON null. + assertTrue(bodyCaptor.getValue().isNull("domSnapshot")); + } + + @Test + public void snapshotSwallowsGenericExceptionFromExecutor() throws Exception { + RemoteWebDriver driver = mock(RemoteWebDriver.class); + Percy percy = spy(new Percy(driver)); + setField(percy, "isPercyEnabled", true); + setField(percy, "domJs", "window.PercyDOM = window.PercyDOM || {};"); + setField(percy, "cliConfig", new JSONObject().put("snapshot", new JSONObject())); + percy.sessionType = "web"; + + // A non-WebDriverException -> hits the generic Exception catch (line ~393-396). + when(((JavascriptExecutor) driver).executeScript(anyString())) + .thenThrow(new IllegalStateException("kaboom")); + when(driver.getCurrentUrl()).thenReturn("https://example.com"); + + doReturn(new JSONObject().put("name", "generic")).when(percy) + .request(eq("/percy/snapshot"), any(JSONObject.class), eq("generic")); + + JSONObject data = percy.snapshot("generic"); + assertEquals("generic", data.getString("name")); + } + + // ------------------------------------------------------------------ + // healthcheck: missing x-percy-core-version header -> disabled. + // ------------------------------------------------------------------ + + @Test + public void healthcheckDisablesWhenVersionHeaderMissing() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/percy/healthcheck", new HttpHandler() { + @Override + public void handle(HttpExchange exchange) throws IOException { + // Header value explicitly null triggers the `version == null` branch. + exchange.getResponseHeaders().add("x-percy-core-version", ""); + byte[] body = "{}".getBytes("UTF-8"); + exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, body.length); + try (OutputStream os = exchange.getResponseBody()) { os.write(body); } + } + }); + server.start(); + + String original = getStaticStringField(Percy.class, "PERCY_SERVER_ADDRESS"); + try { + setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", "http://localhost:" + server.getAddress().getPort()); + Percy percy = new Percy(mock(RemoteWebDriver.class)); + assertFalse(getBooleanField(percy, "isPercyEnabled")); + } finally { + setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", original); + server.stop(0); + } + } + + // ------------------------------------------------------------------ + // fetchPercyDOM: non-200 and connection-failure paths disable Percy. + // ------------------------------------------------------------------ + + @Test + public void fetchPercyDomDisablesPercyOnNon200() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/percy/dom.js", new HttpHandler() { + @Override + public void handle(HttpExchange exchange) throws IOException { + byte[] body = "nope".getBytes("UTF-8"); + exchange.sendResponseHeaders(HttpURLConnection.HTTP_INTERNAL_ERROR, body.length); + try (OutputStream os = exchange.getResponseBody()) { os.write(body); } + } + }); + server.start(); + + String original = getStaticStringField(Percy.class, "PERCY_SERVER_ADDRESS"); + try { + setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", "http://localhost:" + server.getAddress().getPort()); + Percy percy = spy(new Percy(mock(RemoteWebDriver.class))); + setField(percy, "isPercyEnabled", true); + + String dom = (String) invokePrivate(percy, "fetchPercyDOM", new Class[]{}); + assertEquals("", dom); + // The non-200 throw is caught and Percy is disabled. + assertFalse(getBooleanField(percy, "isPercyEnabled")); + } finally { + setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", original); + server.stop(0); + } + } + + // ------------------------------------------------------------------ + // getResponsiveWidths: a non-RuntimeException (e.g. connection refused) + // is wrapped into a RuntimeException (lines ~288-292). + // ------------------------------------------------------------------ + + @Test + public void getResponsiveWidthsWrapsConnectionFailure() throws Exception { + String original = getStaticStringField(Percy.class, "PERCY_SERVER_ADDRESS"); + try { + // Point at a closed port so httpClient.execute throws a plain IOException + // (HttpHostConnectException), exercising the generic Exception catch. + setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", "http://localhost:1"); + Percy percy = spy(new Percy(mock(RemoteWebDriver.class))); + + InvocationTargetException ex = assertThrows(InvocationTargetException.class, + () -> invokePrivate(percy, "getResponsiveWidths", new Class[]{List.class}, Arrays.asList(375))); + assertTrue(ex.getCause() instanceof RuntimeException); + assertTrue(ex.getCause().getMessage().contains("Failed to fetch widths-config:")); + } finally { + setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", original); + } + } + + // ------------------------------------------------------------------ + // waitForReady: sets and restores the async-script timeout when the + // resolved readiness config carries a positive timeoutMs. + // ------------------------------------------------------------------ + + @Test + public void waitForReadySetsAndRestoresScriptTimeout() throws Exception { + // A driver that is also a JavascriptExecutor so the instanceof WebDriver + // branches that read/restore the script timeout execute. + RemoteWebDriver driver = mock(RemoteWebDriver.class); + Percy percy = spy(new Percy(driver)); + setField(percy, "cliConfig", new JSONObject().put("snapshot", + new JSONObject().put("readiness", new JSONObject().put("timeoutMs", 5000)))); + + WebDriver.Options manageOptions = mock(WebDriver.Options.class); + Timeouts timeouts = mock(Timeouts.class); + when(driver.manage()).thenReturn(manageOptions); + when(manageOptions.timeouts()).thenReturn(timeouts); + Duration previous = Duration.ofSeconds(10); + when(timeouts.getScriptTimeout()).thenReturn(previous); + when(timeouts.scriptTimeout(any(Duration.class))).thenReturn(timeouts); + + Map diagnostics = new HashMap(); + diagnostics.put("ok", true); + when(driver.executeAsyncScript(anyString())).thenReturn(diagnostics); + + Object result = percy.waitForReady((JavascriptExecutor) driver, new HashMap()); + assertEquals(diagnostics, result); + + ArgumentCaptor durationCaptor = ArgumentCaptor.forClass(Duration.class); + verify(timeouts, times(2)).scriptTimeout(durationCaptor.capture()); + // First set: timeoutMs + 2000ms buffer; last set: restored previous. + assertEquals(Duration.ofMillis(7000L), durationCaptor.getAllValues().get(0)); + assertEquals(previous, durationCaptor.getAllValues().get(1)); + } + + @Test + public void waitForReadyContinuesWhenTimeoutLookupThrows() throws Exception { + // manage() throws -> the best-effort try/catch around the timeout setup + // (previousTimeout = null) is exercised, and serialize proceeds. + RemoteWebDriver driver = mock(RemoteWebDriver.class); + Percy percy = spy(new Percy(driver)); + setField(percy, "cliConfig", new JSONObject().put("snapshot", + new JSONObject().put("readiness", new JSONObject().put("timeoutMs", 1000)))); + + when(driver.manage()).thenThrow(new WebDriverException("no timeouts API")); + when(driver.executeAsyncScript(anyString())).thenReturn(null); + + Object result = percy.waitForReady((JavascriptExecutor) driver, new HashMap()); + assertNull(result); + } + + // ------------------------------------------------------------------ + // resolveReadinessConfig: per-snapshot readiness supplied as a JSONObject. + // ------------------------------------------------------------------ + + @Test + public void resolveReadinessConfigAcceptsJsonObjectPerSnapshot() throws Exception { + Percy percy = spy(new Percy(mock(RemoteWebDriver.class))); + Map options = new HashMap(); + options.put("readiness", new JSONObject().put("preset", "default").put("timeoutMs", 2500)); + + JSONObject merged = (JSONObject) invokePrivate( + percy, "resolveReadinessConfig", new Class[]{Map.class}, options); + assertEquals(2500, merged.getInt("timeoutMs")); + assertEquals("default", merged.getString("preset")); + } + + // ------------------------------------------------------------------ + // FatalIframeException: surfaced when defaultContent() fails after a frame. + // ------------------------------------------------------------------ + + @Test + public void getSerializedDomThrowsFatalIframeWhenDefaultContentFails() throws Exception { + RemoteWebDriver driver = mock(RemoteWebDriver.class); + Percy percy = spy(new Percy(driver)); + setField(percy, "domJs", "window.PercyDOM = window.PercyDOM || {};"); + + WebElement iframe = mock(WebElement.class); + when(iframe.getAttribute("src")).thenReturn("https://cdn.other.com/frame"); + when(iframe.getAttribute("data-percy-element-id")).thenReturn("frame-1"); + + when(driver.getCurrentUrl()).thenReturn("https://app.example.com/page"); + when(driver.findElements(By.tagName("iframe"))).thenReturn(Collections.singletonList(iframe)); + + TargetLocator targetLocator = mock(TargetLocator.class); + when(driver.switchTo()).thenReturn(targetLocator); + when(targetLocator.frame(iframe)).thenReturn(driver); + // defaultContent throws -> finally wraps into FatalIframeException, which + // propagates out of getSerializedDOM via the FatalIframeException rethrow. + when(targetLocator.defaultContent()).thenThrow(new WebDriverException("stuck in frame")); + + Map mainSnapshot = new HashMap(); + mainSnapshot.put("dom", "main"); + when(driver.executeScript(anyString())).thenReturn(mainSnapshot); + + RuntimeException ex = assertThrows(RuntimeException.class, () -> percy.getSerializedDOM( + (JavascriptExecutor) driver, new HashSet(), new HashMap())); + assertTrue(ex.getMessage().contains("Could not exit iframe context")); + } + + @Test + public void getSerializedDomThrowsFatalIframeFromProcessFrameSerializeFailure() throws Exception { + // processFrame's serialize executeScript throws -> processFrame wraps and + // rethrows a RuntimeException; defaultContent() succeeds so it is the + // serialize failure (not FatalIframeException) that surfaces, and the + // generic catch in getSerializedDOM swallows it (frame skipped). + RemoteWebDriver driver = mock(RemoteWebDriver.class); + Percy percy = spy(new Percy(driver)); + setField(percy, "domJs", "window.PercyDOM = window.PercyDOM || {};"); + + WebElement iframe = mock(WebElement.class); + when(iframe.getAttribute("src")).thenReturn("https://cdn.other.com/frame"); + when(iframe.getAttribute("data-percy-element-id")).thenReturn("frame-1"); + + when(driver.getCurrentUrl()).thenReturn("https://app.example.com/page"); + when(driver.findElements(By.tagName("iframe"))).thenReturn(Collections.singletonList(iframe)); + + TargetLocator targetLocator = mock(TargetLocator.class); + when(driver.switchTo()).thenReturn(targetLocator); + when(targetLocator.frame(iframe)).thenReturn(driver); + when(targetLocator.defaultContent()).thenReturn(driver); + + when(driver.executeScript(anyString())).thenAnswer(invocation -> { + String script = invocation.getArgument(0); + // The in-frame serialize (enableJavaScript:true) throws. + if (script.contains("\"enableJavaScript\":true")) { + throw new WebDriverException("frame serialize failed"); + } + Map main = new HashMap(); + main.put("dom", "main"); + return main; + }); + + @SuppressWarnings("unchecked") + Map serialized = (Map) percy.getSerializedDOM( + (JavascriptExecutor) driver, new HashSet(), new HashMap()); + // Frame was skipped; no corsIframes attached. + assertFalse(serialized.containsKey("corsIframes")); + } + + @Test + public void getSerializedDomSkipsIframeWithMissingPercyElementId() throws Exception { + // processFrame returns null when no data-percy-element-id is present. + RemoteWebDriver driver = mock(RemoteWebDriver.class); + Percy percy = spy(new Percy(driver)); + setField(percy, "domJs", "window.PercyDOM = window.PercyDOM || {};"); + + WebElement iframe = mock(WebElement.class); + when(iframe.getAttribute("src")).thenReturn("https://cdn.other.com/frame"); + when(iframe.getAttribute("data-percy-element-id")).thenReturn(null); + + when(driver.getCurrentUrl()).thenReturn("https://app.example.com/page"); + when(driver.findElements(By.tagName("iframe"))).thenReturn(Collections.singletonList(iframe)); + + TargetLocator targetLocator = mock(TargetLocator.class); + when(driver.switchTo()).thenReturn(targetLocator); + when(targetLocator.frame(iframe)).thenReturn(driver); + when(targetLocator.defaultContent()).thenReturn(driver); + + Map main = new HashMap(); + main.put("dom", "main"); + when(driver.executeScript(anyString())).thenReturn(main); + + @SuppressWarnings("unchecked") + Map serialized = (Map) percy.getSerializedDOM( + (JavascriptExecutor) driver, new HashSet(), new HashMap()); + assertFalse(serialized.containsKey("corsIframes")); + } + + @Test + public void getSerializedDomSkipsIframeWithUnresolvableSrc() throws Exception { + // A frame src that makes base.resolve() throw -> the URI-resolve catch + // (lines ~816-818) logs and skips the frame. + RemoteWebDriver driver = mock(RemoteWebDriver.class); + Percy percy = spy(new Percy(driver)); + setField(percy, "domJs", "window.PercyDOM = window.PercyDOM || {};"); + + WebElement iframe = mock(WebElement.class); + when(iframe.getAttribute("src")).thenReturn("ht!tp://%%%bad uri"); + + when(driver.getCurrentUrl()).thenReturn("https://app.example.com/page"); + when(driver.findElements(By.tagName("iframe"))).thenReturn(Collections.singletonList(iframe)); + + Map main = new HashMap(); + main.put("dom", "main"); + when(driver.executeScript(anyString())).thenReturn(main); + + @SuppressWarnings("unchecked") + Map serialized = (Map) percy.getSerializedDOM( + (JavascriptExecutor) driver, new HashSet(), new HashMap()); + assertFalse(serialized.containsKey("corsIframes")); + } + + @Test + public void getSerializedDomPropagatesFatalIframeFromLoop() throws Exception { + // FatalIframeException thrown inside the per-frame try must be rethrown + // (lines ~828-831) rather than swallowed, then rethrown again by the + // outer FatalIframeException catch (lines ~838-839). + RemoteWebDriver driver = mock(RemoteWebDriver.class); + Percy percy = spy(new Percy(driver)); + setField(percy, "domJs", "window.PercyDOM = window.PercyDOM || {};"); + + WebElement iframe = mock(WebElement.class); + when(iframe.getAttribute("src")).thenReturn("https://cdn.other.com/frame"); + when(iframe.getAttribute("data-percy-element-id")).thenReturn("frame-1"); + + when(driver.getCurrentUrl()).thenReturn("https://app.example.com/page"); + when(driver.findElements(By.tagName("iframe"))).thenReturn(Collections.singletonList(iframe)); + + TargetLocator targetLocator = mock(TargetLocator.class); + when(driver.switchTo()).thenReturn(targetLocator); + when(targetLocator.frame(iframe)).thenReturn(driver); + when(targetLocator.defaultContent()).thenThrow(new WebDriverException("stuck")); + + Map main = new HashMap(); + main.put("dom", "main"); + when(driver.executeScript(anyString())).thenReturn(main); + + assertThrows(Percy.FatalIframeException.class, () -> percy.getSerializedDOM( + (JavascriptExecutor) driver, new HashSet(), new HashMap())); + } + + // ------------------------------------------------------------------ + // captureResponsiveDom: CDP resize path, CDP fallback, resize-wait timeout, + // non-numeric width skip, and sleep handling. + // ------------------------------------------------------------------ + + @Test + public void captureResponsiveDomUsesCdpResizePathForChromeDriver() throws Exception { + HttpServer server = startWidthsConfigServer( + "{\"widths\":[{\"width\":375,\"height\":812},{\"width\":1280}]}", + HttpURLConnection.HTTP_OK); + String original = getStaticStringField(Percy.class, "PERCY_SERVER_ADDRESS"); + try { + setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", "http://localhost:" + server.getAddress().getPort()); + + ChromeDriver driver = mock(ChromeDriver.class); + Percy percy = spy(new Percy(driver)); + setField(percy, "domJs", "/* percy */"); + setField(percy, "cliConfig", new JSONObject().put("snapshot", new JSONObject())); + + WebDriver.Options driverOptions = mock(WebDriver.Options.class); + WebDriver.Window driverWindow = mock(WebDriver.Window.class); + when(driver.manage()).thenReturn(driverOptions); + when(driverOptions.window()).thenReturn(driverWindow); + when(driverWindow.getSize()).thenReturn(new Dimension(1024, 768)); + + // The CDP resize bumps an internal counter that executeScript reports + // back as window.resizeCount so WebDriverWait resolves immediately. + AtomicInteger resizeCount = new AtomicInteger(0); + when(driver.executeCdpCommand(eq("Emulation.setDeviceMetricsOverride"), anyMap())) + .thenAnswer(inv -> { resizeCount.incrementAndGet(); return new HashMap(); }); + + when(driver.getCurrentUrl()).thenReturn("https://example.com"); + when(driver.findElements(By.tagName("iframe"))).thenReturn(Collections.emptyList()); + when(driver.executeScript(anyString())).thenAnswer(invocation -> { + String script = invocation.getArgument(0); + if (script.equals("return window.resizeCount")) { + return (long) resizeCount.get(); + } + if (script.startsWith("return PercyDOM.serialize(")) { + Map snap = new HashMap(); + snap.put("dom", "x"); + return snap; + } + return null; + }); + + Map options = new HashMap(); + options.put("widths", Arrays.asList(375, 1280)); + List> snapshots = + percy.captureResponsiveDom(driver, new HashSet(), options); + + assertEquals(2, snapshots.size()); + assertEquals(375, snapshots.get(0).get("width")); + assertEquals(1280, snapshots.get(1).get("width")); + // CDP used for every resize (2 widths + final restore). + verify(driver, times(3)).executeCdpCommand(eq("Emulation.setDeviceMetricsOverride"), anyMap()); + verify(driverWindow, never()).setSize(any(Dimension.class)); + } finally { + setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", original); + server.stop(0); + } + } + + @Test + public void captureResponsiveDomFallsBackToSetSizeWhenCdpThrows() throws Exception { + HttpServer server = startWidthsConfigServer( + "{\"widths\":[{\"width\":375}]}", HttpURLConnection.HTTP_OK); + String original = getStaticStringField(Percy.class, "PERCY_SERVER_ADDRESS"); + try { + setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", "http://localhost:" + server.getAddress().getPort()); + + ChromeDriver driver = mock(ChromeDriver.class); + Percy percy = spy(new Percy(driver)); + setField(percy, "domJs", "/* percy */"); + setField(percy, "cliConfig", new JSONObject().put("snapshot", new JSONObject())); + + WebDriver.Options driverOptions = mock(WebDriver.Options.class); + WebDriver.Window driverWindow = mock(WebDriver.Window.class); + when(driver.manage()).thenReturn(driverOptions); + when(driverOptions.window()).thenReturn(driverWindow); + when(driverWindow.getSize()).thenReturn(new Dimension(1024, 768)); + + AtomicInteger resizeCount = new AtomicInteger(0); + // CDP throws -> the catch logs and falls back to window().setSize(). + when(driver.executeCdpCommand(anyString(), anyMap())) + .thenThrow(new WebDriverException("cdp unsupported")); + doAnswer(inv -> { resizeCount.incrementAndGet(); return null; }) + .when(driverWindow).setSize(any(Dimension.class)); + + when(driver.getCurrentUrl()).thenReturn("https://example.com"); + when(driver.findElements(By.tagName("iframe"))).thenReturn(Collections.emptyList()); + when(driver.executeScript(anyString())).thenAnswer(invocation -> { + String script = invocation.getArgument(0); + if (script.equals("return window.resizeCount")) { + return (long) resizeCount.get(); + } + if (script.startsWith("return PercyDOM.serialize(")) { + Map snap = new HashMap(); + snap.put("dom", "x"); + return snap; + } + return null; + }); + + Map options = new HashMap(); + options.put("widths", Arrays.asList(375)); + List> snapshots = + percy.captureResponsiveDom(driver, new HashSet(), options); + + assertEquals(1, snapshots.size()); + // Fallback setSize used for resize + final restore. + verify(driverWindow, atLeast(2)).setSize(any(Dimension.class)); + } finally { + setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", original); + server.stop(0); + } + } + + @Test + public void captureResponsiveDomTolueratesResizeWaitTimeout() throws Exception { + // window.resizeCount never matches -> WebDriverWait times out and the + // WebDriverException catch (lines ~893-894) logs but capture continues. + HttpServer server = startWidthsConfigServer( + "{\"widths\":[{\"width\":375}]}", HttpURLConnection.HTTP_OK); + String original = getStaticStringField(Percy.class, "PERCY_SERVER_ADDRESS"); + String originalSleep = getStaticStringField(Percy.class, "RESPONSIVE_CAPTURE_SLEEP_TIME"); + try { + setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", "http://localhost:" + server.getAddress().getPort()); + // Also exercise the sleep block (lines ~1000-1003) with a tiny sleep. + setStaticField(Percy.class, "RESPONSIVE_CAPTURE_SLEEP_TIME", "0"); + + ChromeDriver driver = mock(ChromeDriver.class); + Percy percy = spy(new Percy(driver)); + setField(percy, "domJs", "/* percy */"); + setField(percy, "cliConfig", new JSONObject().put("snapshot", new JSONObject())); + + WebDriver.Options driverOptions = mock(WebDriver.Options.class); + WebDriver.Window driverWindow = mock(WebDriver.Window.class); + when(driver.manage()).thenReturn(driverOptions); + when(driverOptions.window()).thenReturn(driverWindow); + when(driverWindow.getSize()).thenReturn(new Dimension(1024, 768)); + when(driver.executeCdpCommand(anyString(), anyMap())).thenReturn(new HashMap()); + + when(driver.getCurrentUrl()).thenReturn("https://example.com"); + when(driver.findElements(By.tagName("iframe"))).thenReturn(Collections.emptyList()); + when(driver.executeScript(anyString())).thenAnswer(invocation -> { + String script = invocation.getArgument(0); + if (script.equals("return window.resizeCount")) { + // Never matches the expected resizeCount -> forces a wait timeout. + return 999999L; + } + if (script.startsWith("return PercyDOM.serialize(")) { + Map snap = new HashMap(); + snap.put("dom", "x"); + return snap; + } + return null; + }); + + Map options = new HashMap(); + options.put("widths", Arrays.asList(375)); + List> snapshots = + percy.captureResponsiveDom(driver, new HashSet(), options); + assertEquals(1, snapshots.size()); + } finally { + setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", original); + setStaticField(Percy.class, "RESPONSIVE_CAPTURE_SLEEP_TIME", originalSleep); + server.stop(0); + } + } + + // ------------------------------------------------------------------ + // snapshot 7-arg overload delegates to the 8-arg form (line ~260). + // ------------------------------------------------------------------ + + @Test + public void sevenArgSnapshotOverloadDelegates() throws Exception { + Percy percy = spy(new Percy(mock(RemoteWebDriver.class))); + setField(percy, "isPercyEnabled", false); // 8-arg returns null fast. + assertNull(percy.snapshot("n", Arrays.asList(800), 600, false, "css", "scope", true)); + } + + // ------------------------------------------------------------------ + // getSerializedDOM: a non-Fatal failure outside the per-frame inner try + // (here findElements throws) hits the outer generic catch (lines ~840-841) + // and the snapshot is still returned without corsIframes. + // ------------------------------------------------------------------ + + @Test + public void getSerializedDomSwallowsOuterIframeDiscoveryFailure() throws Exception { + RemoteWebDriver driver = mock(RemoteWebDriver.class); + Percy percy = spy(new Percy(driver)); + setField(percy, "domJs", "window.PercyDOM = window.PercyDOM || {};"); + + when(driver.getCurrentUrl()).thenReturn("https://app.example.com/page"); + // findElements throwing is not a FatalIframeException, so it is caught by + // the outer generic catch rather than propagating. + when(driver.findElements(By.tagName("iframe"))) + .thenThrow(new WebDriverException("iframe discovery blew up")); + + Map main = new HashMap(); + main.put("dom", "main"); + when(driver.executeScript(anyString())).thenReturn(main); + + @SuppressWarnings("unchecked") + Map serialized = (Map) percy.getSerializedDOM( + (JavascriptExecutor) driver, new HashSet(), new HashMap()); + assertFalse(serialized.containsKey("corsIframes")); + assertEquals("main", serialized.get("dom")); + } + + // ------------------------------------------------------------------ + // captureResponsiveDom: a non-numeric RESPONSIVE_CAPTURE_SLEEP_TIME makes + // Integer.parseInt throw inside the sleep block; the shared + // InterruptedException|NumberFormatException catch (line ~1003) swallows it + // and capture proceeds. Also drives the resizeCount==null wait branch + // (line ~889) by returning null for window.resizeCount. + // ------------------------------------------------------------------ + + @Test + public void captureResponsiveDomHandlesNonNumericSleepAndNullResizeCount() throws Exception { + HttpServer server = startWidthsConfigServer( + "{\"widths\":[{\"width\":375}]}", HttpURLConnection.HTTP_OK); + String original = getStaticStringField(Percy.class, "PERCY_SERVER_ADDRESS"); + String originalSleep = getStaticStringField(Percy.class, "RESPONSIVE_CAPTURE_SLEEP_TIME"); + try { + setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", "http://localhost:" + server.getAddress().getPort()); + // Non-numeric -> Integer.parseInt throws NumberFormatException (caught). + setStaticField(Percy.class, "RESPONSIVE_CAPTURE_SLEEP_TIME", "abc"); + + ChromeDriver driver = mock(ChromeDriver.class); + Percy percy = spy(new Percy(driver)); + setField(percy, "domJs", "/* percy */"); + setField(percy, "cliConfig", new JSONObject().put("snapshot", new JSONObject())); + + WebDriver.Options driverOptions = mock(WebDriver.Options.class); + WebDriver.Window driverWindow = mock(WebDriver.Window.class); + when(driver.manage()).thenReturn(driverOptions); + when(driverOptions.window()).thenReturn(driverWindow); + when(driverWindow.getSize()).thenReturn(new Dimension(1024, 768)); + when(driver.executeCdpCommand(anyString(), anyMap())).thenReturn(new HashMap()); + + when(driver.getCurrentUrl()).thenReturn("https://example.com"); + when(driver.findElements(By.tagName("iframe"))).thenReturn(Collections.emptyList()); + when(driver.executeScript(anyString())).thenAnswer(invocation -> { + String script = invocation.getArgument(0); + if (script.equals("return window.resizeCount")) { + // null -> the wait lambda's `resizeCountObj == null` branch returns false. + return null; + } + if (script.startsWith("return PercyDOM.serialize(")) { + Map snap = new HashMap(); + snap.put("dom", "x"); + return snap; + } + return null; + }); + + Map options = new HashMap(); + options.put("widths", Arrays.asList(375)); + List> snapshots = + percy.captureResponsiveDom(driver, new HashSet(), 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> widths = (List>) invokePrivate( + mockedPercy, "getResponsiveWidths", new Class[]{List.class}, Arrays.asList(375, 1280)); + + assertEquals(2, widths.size()); + assertEquals(375, widths.get(0).get("width")); + assertFalse(widths.get(0).containsKey("height")); + assertEquals(1280, widths.get(1).get("width")); + assertEquals(900, widths.get(1).get("height")); + } finally { + setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", originalAddress); + server.stop(0); + } + } + + @Test + public void getResponsiveWidthsThrowsOnNon200() throws Exception { + HttpServer server = startWidthsConfigServer("{}", HttpURLConnection.HTTP_INTERNAL_ERROR); + 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))); + + InvocationTargetException exception = assertThrows(InvocationTargetException.class, + () -> invokePrivate(mockedPercy, "getResponsiveWidths", new Class[]{List.class}, Arrays.asList(375))); + assertTrue(exception.getCause().getMessage().contains("Failed to fetch widths-config (HTTP 500)")); + } finally { + setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", originalAddress); + server.stop(0); + } + } + + @Test + public void buildRequestConfigSetsTimeouts() throws Exception { + Percy mockedPercy = spy(new Percy(mock(RemoteWebDriver.class))); + RequestConfig requestConfig = (RequestConfig) invokePrivate( + mockedPercy, "buildRequestConfig", new Class[]{int.class}, 4321); + assertEquals(4321, requestConfig.getSocketTimeout()); + assertEquals(4321, requestConfig.getConnectTimeout()); + } + + // ------------------------------------------------------------------ + // minHeight / responsive target height helpers + // ------------------------------------------------------------------ + + @Test + public void resolveConfiguredMinHeightFromOptionsThenCliFallbackThenInvalid() throws Exception { + Percy mockedPercy = spy(new Percy(mock(RemoteWebDriver.class))); + + Map opts = new HashMap(); + opts.put("minHeight", "1500"); + assertEquals(1500, + invokePrivate(mockedPercy, "resolveConfiguredMinHeight", new Class[]{Map.class}, opts)); + + setField(mockedPercy, "cliConfig", new JSONObject().put("snapshot", new JSONObject().put("minHeight", 700))); + assertEquals(700, + invokePrivate(mockedPercy, "resolveConfiguredMinHeight", new Class[]{Map.class}, new HashMap())); + + Map badOpts = new HashMap(); + badOpts.put("minHeight", "not-a-number"); + assertNull(invokePrivate(mockedPercy, "resolveConfiguredMinHeight", new Class[]{Map.class}, badOpts)); + } + + @Test + public void resolveResponsiveTargetHeightHonoursFeatureFlag() throws Exception { + Percy mockedPercy = spy(new Percy(mock(RemoteWebDriver.class))); + // Clear any cliConfig that a live Percy CLI (e.g. `percy exec` on CI) may + // have populated, so the "no minHeight anywhere" branch deterministically + // falls back to currentHeight instead of reading cliConfig.snapshot.minHeight. + setField(mockedPercy, "cliConfig", null); + boolean originalFlag = getStaticBooleanField(Percy.class, "PERCY_RESPONSIVE_CAPTURE_MIN_HEIGHT"); + try { + setStaticField(Percy.class, "PERCY_RESPONSIVE_CAPTURE_MIN_HEIGHT", false); + assertEquals(640, invokePrivate(mockedPercy, "resolveResponsiveTargetHeight", + new Class[]{Map.class, int.class}, new HashMap(), 640)); + + setStaticField(Percy.class, "PERCY_RESPONSIVE_CAPTURE_MIN_HEIGHT", true); + Map opts = new HashMap(); + opts.put("minHeight", 999); + assertEquals(999, invokePrivate(mockedPercy, "resolveResponsiveTargetHeight", + new Class[]{Map.class, int.class}, opts, 640)); + + // No minHeight anywhere -> falls back to currentHeight. + assertEquals(640, invokePrivate(mockedPercy, "resolveResponsiveTargetHeight", + new Class[]{Map.class, int.class}, new HashMap(), 640)); + } finally { + setStaticField(Percy.class, "PERCY_RESPONSIVE_CAPTURE_MIN_HEIGHT", originalFlag); + } + } + + @Test + public void extractResponsiveWidthsCoercesNumbersAndStrings() throws Exception { + Percy mockedPercy = spy(new Percy(mock(RemoteWebDriver.class))); + + Map opts = new HashMap(); + opts.put("widths", Arrays.asList(320, "640", "bad", 1024)); + @SuppressWarnings("unchecked") + List result = (List) invokePrivate( + mockedPercy, "extractResponsiveWidths", new Class[]{Map.class}, opts); + assertEquals(Arrays.asList(320, 640, 1024), result); + + // Null options and non-list widths return null. + assertNull(invokePrivate(mockedPercy, "extractResponsiveWidths", new Class[]{Map.class}, new Object[]{null})); + Map noList = new HashMap(); + noList.put("widths", "1280"); + assertNull(invokePrivate(mockedPercy, "extractResponsiveWidths", new Class[]{Map.class}, noList)); + } + + // ------------------------------------------------------------------ + // getOrigin / isUnsupportedIframeSrc / buildSnapshotJS + // ------------------------------------------------------------------ + + @Test + public void getOriginReturnsSchemeAndAuthorityOrEmpty() throws Exception { + Percy mockedPercy = spy(new Percy(mock(RemoteWebDriver.class))); + assertEquals("https://example.com", + invokePrivate(mockedPercy, "getOrigin", new Class[]{String.class}, "https://example.com/path?q=1")); + assertEquals("", + invokePrivate(mockedPercy, "getOrigin", new Class[]{String.class}, "not a url")); + assertEquals("", + invokePrivate(mockedPercy, "getOrigin", new Class[]{String.class}, "/relative/path")); + } + + @Test + public void isUnsupportedIframeSrcDetectsNonHttpSources() throws Exception { + Percy mockedPercy = spy(new Percy(mock(RemoteWebDriver.class))); + for (String unsupported : new String[]{null, "", "about:blank", "javascript:void(0)", "data:text/html,x", "vbscript:foo"}) { + assertTrue((boolean) invokePrivate(mockedPercy, "isUnsupportedIframeSrc", new Class[]{String.class}, unsupported), + "expected unsupported for: " + unsupported); + } + assertFalse((boolean) invokePrivate(mockedPercy, "isUnsupportedIframeSrc", new Class[]{String.class}, "https://cdn.example.com/x")); + } + + @Test + public void buildSnapshotJSStripsReadinessAndWrapsSerialize() throws Exception { + Percy mockedPercy = spy(new Percy(mock(RemoteWebDriver.class))); + Map options = new HashMap(); + options.put("scope", "div"); + options.put("readiness", new HashMap()); + + String js = (String) invokePrivate(mockedPercy, "buildSnapshotJS", new Class[]{Map.class}, options); + assertTrue(js.startsWith("return PercyDOM.serialize(")); + assertTrue(js.contains("\"scope\":\"div\"")); + assertFalse(js.contains("readiness")); + } + + // ------------------------------------------------------------------ + // getSerializedDOM (cookies / iframes / readiness) + // ------------------------------------------------------------------ + + @Test + public void getSerializedDomAttachesCookiesAndSkipsWhenNoIframes() throws Exception { + RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); + Percy mockedPercy = spy(new Percy(mockedDriver)); + setField(mockedPercy, "domJs", "window.PercyDOM = window.PercyDOM || {};"); + + when(mockedDriver.getCurrentUrl()).thenReturn("https://app.example.com"); + when(mockedDriver.findElements(By.tagName("iframe"))).thenReturn(Collections.emptyList()); + Map snap = new HashMap(); + snap.put("dom", "main"); + when(((JavascriptExecutor) mockedDriver).executeScript(anyString())).thenReturn(snap); + + Set cookies = new HashSet(); + cookies.add(new Cookie("a", "b")); + + @SuppressWarnings("unchecked") + Map serialized = (Map) invokePrivate( + mockedPercy, "getSerializedDOM", + new Class[]{JavascriptExecutor.class, Set.class, Map.class}, + mockedDriver, cookies, new HashMap()); + + assertEquals(cookies, serialized.get("cookies")); + assertFalse(serialized.containsKey("corsIframes")); + } + + @Test + public void getSerializedDomCapturesCrossOriginIframe() throws Exception { + RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); + Percy mockedPercy = spy(new Percy(mockedDriver)); + setField(mockedPercy, "domJs", "window.PercyDOM = window.PercyDOM || {};"); + + WebElement iframe = mock(WebElement.class); + when(iframe.getAttribute("src")).thenReturn("https://cdn.other.com/frame"); + when(iframe.getAttribute("data-percy-element-id")).thenReturn("frame-1"); + + when(mockedDriver.getCurrentUrl()).thenReturn("https://app.example.com/page"); + when(mockedDriver.findElements(By.tagName("iframe"))).thenReturn(Collections.singletonList(iframe)); + + TargetLocator targetLocator = mock(TargetLocator.class); + when(mockedDriver.switchTo()).thenReturn(targetLocator); + when(targetLocator.frame(iframe)).thenReturn(mockedDriver); + when(targetLocator.defaultContent()).thenReturn(mockedDriver); + + Map mainSnapshot = new HashMap(); + mainSnapshot.put("dom", "main"); + Map iframeSnapshot = new HashMap(); + iframeSnapshot.put("dom", "iframe"); + + when(((JavascriptExecutor) mockedDriver).executeScript(anyString())).thenAnswer(invocation -> { + String script = invocation.getArgument(0); + if (script.startsWith("return PercyDOM.serialize(") && script.contains("\"enableJavaScript\":true")) { + return iframeSnapshot; + } + return mainSnapshot; + }); + + @SuppressWarnings("unchecked") + Map serialized = (Map) invokePrivate( + mockedPercy, "getSerializedDOM", + new Class[]{JavascriptExecutor.class, Set.class, Map.class}, + mockedDriver, new HashSet(), new HashMap()); + + @SuppressWarnings("unchecked") + List> corsIframes = (List>) serialized.get("corsIframes"); + assertEquals(1, corsIframes.size()); + assertEquals("https://cdn.other.com/frame", corsIframes.get(0).get("frameUrl")); + } + + @Test + public void getSerializedDomAttachesReadinessDiagnostics() throws Exception { + RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); + Percy mockedPercy = spy(new Percy(mockedDriver)); + setField(mockedPercy, "cliConfig", new JSONObject().put("snapshot", new JSONObject())); + + Map diagnostics = new HashMap(); + diagnostics.put("ok", true); + when(((JavascriptExecutor) mockedDriver).executeAsyncScript(anyString())).thenReturn(diagnostics); + Map domSnap = new HashMap(); + domSnap.put("html", ""); + when(((JavascriptExecutor) mockedDriver).executeScript(anyString())).thenReturn(domSnap); + + Map result = mockedPercy.getSerializedDOM( + (JavascriptExecutor) mockedDriver, new HashSet(), new HashMap()); + + assertEquals(diagnostics, result.get("readiness_diagnostics")); + } + + @Test + public void getSerializedDomSkipsReadinessWhenPresetDisabled() throws Exception { + RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); + Percy mockedPercy = spy(new Percy(mockedDriver)); + + Map domSnap = new HashMap(); + domSnap.put("html", ""); + when(((JavascriptExecutor) mockedDriver).executeScript(anyString())).thenReturn(domSnap); + + Map disabled = new HashMap(); + disabled.put("preset", "disabled"); + Map options = new HashMap(); + options.put("readiness", disabled); + + Map result = mockedPercy.getSerializedDOM( + (JavascriptExecutor) mockedDriver, new HashSet(), options); + + verify((JavascriptExecutor) mockedDriver, never()).executeAsyncScript(anyString()); + assertNull(result.get("readiness_diagnostics")); + } + + // ------------------------------------------------------------------ + // resolveReadinessConfig / waitForReady + // ------------------------------------------------------------------ + + @Test + public void resolveReadinessConfigMergesGlobalAndPerSnapshot() throws Exception { + Percy mockedPercy = spy(new Percy(mock(RemoteWebDriver.class))); + setField(mockedPercy, "cliConfig", new JSONObject().put("snapshot", + new JSONObject().put("readiness", new JSONObject().put("preset", "default").put("timeoutMs", 1000)))); + + Map perSnapshot = new HashMap(); + perSnapshot.put("timeoutMs", 5000); + Map options = new HashMap(); + options.put("readiness", perSnapshot); + + JSONObject merged = (JSONObject) invokePrivate( + mockedPercy, "resolveReadinessConfig", new Class[]{Map.class}, options); + + // Per-snapshot timeout wins, global preset inherited. + assertEquals(5000, merged.getInt("timeoutMs")); + assertEquals("default", merged.getString("preset")); + } + + @Test + public void waitForReadyReturnsNullWhenPresetDisabled() throws Exception { + RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); + Percy mockedPercy = spy(new Percy(mockedDriver)); + + Map disabled = new HashMap(); + disabled.put("preset", "disabled"); + Map options = new HashMap(); + options.put("readiness", disabled); + + Object result = mockedPercy.waitForReady((JavascriptExecutor) mockedDriver, options); + assertNull(result); + verify((JavascriptExecutor) mockedDriver, never()).executeAsyncScript(anyString()); + } + + @Test + public void waitForReadyReturnsNullWhenAsyncScriptThrows() throws Exception { + RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); + Percy mockedPercy = spy(new Percy(mockedDriver)); + setField(mockedPercy, "cliConfig", new JSONObject().put("snapshot", new JSONObject())); + + when(((JavascriptExecutor) mockedDriver).executeAsyncScript(anyString())) + .thenThrow(new RuntimeException("boom")); + + Object result = mockedPercy.waitForReady((JavascriptExecutor) mockedDriver, new HashMap()); + assertNull(result); + } + + // ------------------------------------------------------------------ + // captureResponsiveDom (resize logic) + // ------------------------------------------------------------------ + + @Test + public void captureResponsiveDomResizesPerWidthAndRestores() throws Exception { + HttpServer server = startWidthsConfigServer( + "{\"widths\":[{\"width\":375,\"height\":812},{\"width\":1280}]}", HttpURLConnection.HTTP_OK); + String originalAddress = getStaticStringField(Percy.class, "PERCY_SERVER_ADDRESS"); + try { + setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", "http://localhost:" + server.getAddress().getPort()); + + RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); + Percy mockedPercy = spy(new Percy(mockedDriver)); + setField(mockedPercy, "domJs", "/* percy */"); + setField(mockedPercy, "cliConfig", new JSONObject().put("snapshot", new JSONObject())); + + AtomicInteger changes = new AtomicInteger(0); + WebDriver.Options driverOptions = mock(WebDriver.Options.class); + WebDriver.Window driverWindow = mock(WebDriver.Window.class); + when(mockedDriver.manage()).thenReturn(driverOptions); + when(driverOptions.window()).thenReturn(driverWindow); + when(driverWindow.getSize()).thenReturn(new Dimension(1024, 768)); + doAnswer(inv -> { changes.incrementAndGet(); return null; }).when(driverWindow).setSize(any(Dimension.class)); + + when(mockedDriver.getCurrentUrl()).thenReturn("https://example.com"); + when(mockedDriver.findElements(By.tagName("iframe"))).thenReturn(Collections.emptyList()); + when(((JavascriptExecutor) mockedDriver).executeScript(anyString())).thenAnswer(invocation -> { + String script = invocation.getArgument(0); + if (script.equals("return window.resizeCount")) { + return (long) changes.get(); + } + if (script.startsWith("return PercyDOM.serialize(")) { + Map snap = new HashMap(); + snap.put("dom", "x"); + return snap; + } + return null; + }); + + Map options = new HashMap(); + options.put("widths", Arrays.asList(375, 1280)); + List> snapshots = + mockedPercy.captureResponsiveDom(mockedDriver, new HashSet(), options); + + ArgumentCaptor sizeCaptor = ArgumentCaptor.forClass(Dimension.class); + verify(driverWindow, times(3)).setSize(sizeCaptor.capture()); + List sizes = sizeCaptor.getAllValues(); + assertEquals(375, sizes.get(0).getWidth()); + assertEquals(812, sizes.get(0).getHeight()); + assertEquals(1280, sizes.get(1).getWidth()); + assertEquals(768, sizes.get(1).getHeight()); + assertEquals(1024, sizes.get(2).getWidth()); + + assertEquals(2, snapshots.size()); + assertEquals(375, snapshots.get(0).get("width")); + assertEquals(1280, snapshots.get(1).get("width")); + } finally { + setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", originalAddress); + server.stop(0); + } + } + + // ------------------------------------------------------------------ + // request / postSnapshot via in-process HTTP server + // ------------------------------------------------------------------ + + @Test + public void requestReturnsDataObjectOnSuccess() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/percy/snapshot", new HttpHandler() { + @Override + public void handle(HttpExchange exchange) throws IOException { + byte[] body = "{\"data\":{\"name\":\"posted\"}}".getBytes("UTF-8"); + exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, body.length); + try (OutputStream os = exchange.getResponseBody()) { os.write(body); } + } + }); + server.start(); + + 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))); + + JSONObject result = mockedPercy.request("/percy/snapshot", new JSONObject().put("x", 1), "posted"); + assertNotNull(result); + assertEquals("posted", result.getString("name")); + } finally { + setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", originalAddress); + server.stop(0); + } + } + + @Test + public void requestReturnsNullWhenNoDataKey() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/percy/snapshot", new HttpHandler() { + @Override + public void handle(HttpExchange exchange) throws IOException { + byte[] body = "{\"success\":true}".getBytes("UTF-8"); + exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, body.length); + try (OutputStream os = exchange.getResponseBody()) { os.write(body); } + } + }); + server.start(); + + 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))); + + assertNull(mockedPercy.request("/percy/snapshot", new JSONObject(), "no-data")); + } finally { + setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", originalAddress); + server.stop(0); + } + } + + @Test + public void postSnapshotStripsReadinessAndPostsUrlAndName() throws Exception { + RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); + Percy mockedPercy = spy(new Percy(mockedDriver)); + setField(mockedPercy, "isPercyEnabled", true); + + ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(JSONObject.class); + doReturn(new JSONObject()).when(mockedPercy).request(eq("/percy/snapshot"), bodyCaptor.capture(), eq("snap")); + + Map options = new HashMap(); + options.put("readiness", new HashMap()); + options.put("scope", "main"); + + invokePrivate(mockedPercy, "postSnapshot", + new Class[]{Object.class, String.class, String.class, Map.class}, + new HashMap(), "snap", "https://example.com", options); + + JSONObject body = bodyCaptor.getValue(); + assertEquals("https://example.com", body.getString("url")); + assertEquals("snap", body.getString("name")); + assertEquals("main", body.getString("scope")); + assertFalse(body.has("readiness")); + } + + @Test + public void postSnapshotReturnsNullWhenDisabled() throws Exception { + Percy mockedPercy = spy(new Percy(mock(RemoteWebDriver.class))); + // isPercyEnabled stays false. + Object result = invokePrivate(mockedPercy, "postSnapshot", + new Class[]{Object.class, String.class, String.class, Map.class}, + new HashMap(), "snap", "https://example.com", new HashMap()); + assertNull(result); + } + + // ------------------------------------------------------------------ + // fetchPercyDOM / healthcheck (HTTP) + // ------------------------------------------------------------------ + + @Test + public void fetchPercyDomCachesDownloadedScript() throws Exception { + final AtomicInteger hits = new AtomicInteger(0); + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/percy/dom.js", new HttpHandler() { + @Override + public void handle(HttpExchange exchange) throws IOException { + hits.incrementAndGet(); + byte[] body = "window.PercyDOM = {};".getBytes("UTF-8"); + exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, body.length); + try (OutputStream os = exchange.getResponseBody()) { os.write(body); } + } + }); + server.start(); + + 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))); + + String first = (String) invokePrivate(mockedPercy, "fetchPercyDOM", new Class[]{}); + String second = (String) invokePrivate(mockedPercy, "fetchPercyDOM", new Class[]{}); + + assertEquals("window.PercyDOM = {};", first); + assertEquals(first, second); + // Second call uses the cached value, so only one HTTP hit. + assertEquals(1, hits.get()); + } finally { + setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", originalAddress); + server.stop(0); + } + } + + @Test + public void healthcheckParsesTypeWidthsAndConfig() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/percy/healthcheck", new HttpHandler() { + @Override + public void handle(HttpExchange exchange) throws IOException { + exchange.getResponseHeaders().add("x-percy-core-version", "1.2.3"); + byte[] body = ("{\"type\":\"web\",\"widths\":{\"default\":1280}," + + "\"config\":{\"snapshot\":{\"minHeight\":900}}}").getBytes("UTF-8"); + exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, body.length); + try (OutputStream os = exchange.getResponseBody()) { os.write(body); } + } + }); + server.start(); + + String originalAddress = getStaticStringField(Percy.class, "PERCY_SERVER_ADDRESS"); + try { + setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", "http://localhost:" + server.getAddress().getPort()); + // Constructor runs healthcheck against our fake CLI. + Percy percy = new Percy(mock(RemoteWebDriver.class)); + + assertEquals("web", percy.sessionType); + assertNotNull(percy.eligibleWidths); + assertEquals(1280, percy.eligibleWidths.getInt("default")); + + boolean enabled = getBooleanField(percy, "isPercyEnabled"); + assertTrue(enabled); + } finally { + setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", originalAddress); + server.stop(0); + } + } + + @Test + public void healthcheckDisablesForUnsupportedCliMajorVersion() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/percy/healthcheck", new HttpHandler() { + @Override + public void handle(HttpExchange exchange) throws IOException { + exchange.getResponseHeaders().add("x-percy-core-version", "2.0.0"); + byte[] body = "{}".getBytes("UTF-8"); + exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, body.length); + try (OutputStream os = exchange.getResponseBody()) { os.write(body); } + } + }); + server.start(); + + String originalAddress = getStaticStringField(Percy.class, "PERCY_SERVER_ADDRESS"); + try { + setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", "http://localhost:" + server.getAddress().getPort()); + Percy percy = new Percy(mock(RemoteWebDriver.class)); + assertFalse(getBooleanField(percy, "isPercyEnabled")); + } finally { + setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", originalAddress); + server.stop(0); + } + } + + // ------------------------------------------------------------------ + // log + // ------------------------------------------------------------------ + + @Test + public void logSendsToCliAndDoesNotThrow() throws Exception { + final AtomicInteger hits = new AtomicInteger(0); + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/percy/log", new HttpHandler() { + @Override + public void handle(HttpExchange exchange) throws IOException { + hits.incrementAndGet(); + exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, -1); + exchange.close(); + } + }); + server.start(); + + String originalAddress = getStaticStringField(Percy.class, "PERCY_SERVER_ADDRESS"); + try { + setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", "http://localhost:" + server.getAddress().getPort()); + assertDoesNotThrow(() -> Percy.log("hello from test")); + assertEquals(1, hits.get()); + } finally { + setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", originalAddress); + server.stop(0); + } + } + + // ------------------------------------------------------------------ + // Environment + // ------------------------------------------------------------------ + + @Test + public void environmentReportsDefaultClientAndEnvironmentInfo() { + WebDriver driver = mock(RemoteWebDriver.class); + Environment env = new Environment(driver); + + assertTrue(env.getClientInfo().startsWith("percy-java-selenium/")); + assertTrue(env.getEnvironmentInfo().startsWith("selenium-java; ")); + assertEquals(Percy.getSdkVersion(), Environment.getSdkVersion()); + } + + @Test + public void environmentHonoursOverrides() { + Environment env = new Environment(mock(RemoteWebDriver.class)); + env.setClientInfo("percy-cucumber-java-selenium/9.9.9"); + env.setEnvironmentInfo("cucumber-java/7.0; selenium-java"); + + assertEquals("percy-cucumber-java-selenium/9.9.9", env.getClientInfo()); + assertEquals("cucumber-java/7.0; selenium-java", env.getEnvironmentInfo()); + } + + @Test + public void environmentUnwrapsWrappedDriver() { + RemoteWebDriver inner = mock(RemoteWebDriver.class); + WrappingDriver wrapper = mock(WrappingDriver.class); + when(wrapper.getWrappedDriver()).thenReturn(inner); + + Environment env = new Environment(wrapper); + // Reports the inner driver's simple class name, not the wrapper. + assertTrue(env.getEnvironmentInfo().startsWith("selenium-java; ")); + } + + @Test + public void percySetClientInfoOverridesEnvironment() 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"; + mockedPercy.setClientInfo("percy-cucumber-java-selenium/1.0.0", "cucumber-java/7.15.0; selenium-java"); + + when(mockedDriver.getSessionId()).thenReturn(new SessionId("321")); + when(mockedDriver.getCommandExecutor()).thenReturn(commandExecutor); + when(mockedDriver.getCapabilities()).thenReturn(new DesiredCapabilities()); + Cache.CACHE_MAP.clear(); + + ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(JSONObject.class); + doReturn(new JSONObject()).when(mockedPercy).request(eq("/percy/automateScreenshot"), bodyCaptor.capture(), eq("ci")); + + mockedPercy.screenshot("ci"); + + assertEquals("percy-cucumber-java-selenium/1.0.0", bodyCaptor.getValue().getString("clientInfo")); + assertEquals("cucumber-java/7.15.0; selenium-java", bodyCaptor.getValue().getString("environmentInfo")); + } + + // ------------------------------------------------------------------ + // DriverMetadata / Cache (TracedCommandExecutor delegate path) + // ------------------------------------------------------------------ + + @Test + public void driverMetadataCachesCapabilitiesAndExecutorUrl() throws Exception { + Cache.CACHE_MAP.clear(); + RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); + HttpCommandExecutor commandExecutor = mock(HttpCommandExecutor.class); + when(commandExecutor.getAddressOfRemoteServer()).thenReturn(new URL("https://hub-cloud.browserstack.com/wd/hub")); + when(mockedDriver.getSessionId()).thenReturn(new SessionId("meta-1")); + when(mockedDriver.getCommandExecutor()).thenReturn(commandExecutor); + DesiredCapabilities capabilities = new DesiredCapabilities(); + capabilities.setCapability("browserName", "Chrome"); + capabilities.setCapability("deviceName", "iPhone"); + when(mockedDriver.getCapabilities()).thenReturn(capabilities); + + DriverMetadata metadata = new DriverMetadata(mockedDriver); + assertEquals("meta-1", metadata.getSessionId()); + + Map caps = metadata.getCapabilities(); + assertEquals("Chrome", caps.get("browserName")); + assertEquals("iPhone", caps.get("deviceName")); + // Cached: a second call returns the same instance from CACHE_MAP. + assertSame(caps, metadata.getCapabilities()); + + String url = metadata.getCommandExecutorUrl(); + assertEquals("https://hub-cloud.browserstack.com/wd/hub", url); + assertEquals(url, Cache.CACHE_MAP.get("commandExecutorUrl_meta-1")); + } + + // ------------------------------------------------------------------ + // helpers (mirrors SdkTest reflection helpers) + // ------------------------------------------------------------------ + + /** Abstract WebDriver that also implements WrapsDriver so it can be mocked. */ + interface WrappingDriver extends WebDriver, WrapsDriver { } + + 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); + } + + private static boolean getStaticBooleanField(Class clazz, String fieldName) throws Exception { + Field field = clazz.getDeclaredField(fieldName); + field.setAccessible(true); + return (boolean) field.get(null); + } +} diff --git a/src/test/java/io/percy/selenium/cucumber/PercyStepsTest.java b/src/test/java/io/percy/selenium/cucumber/PercyStepsTest.java index a204550..29d60c0 100644 --- a/src/test/java/io/percy/selenium/cucumber/PercyStepsTest.java +++ b/src/test/java/io/percy/selenium/cucumber/PercyStepsTest.java @@ -462,4 +462,115 @@ void testPercyShouldBeEnabledSucceeds() { PercySteps.setDriver(mockDriver); assertDoesNotThrow(steps::percyShouldBeEnabled); } + + // ------------------------------------------------------------------ + // Additional coverage: lazy Percy creation, options-table regions, + // scopeOptions parsing, and getCucumberVersion fallback paths. + // ------------------------------------------------------------------ + + @Test + void testIHaveAPercyInstanceCreatesPercyWhenDriverSetButPercyNull() throws Exception { + // driver set, percy field forced to null -> the `percy == null` branch + // inside iHaveAPercyInstance() constructs a fresh Percy instance. + PercySteps.setDriver(mockDriver); + setPercyField(null); + assertNull(PercySteps.getPercy()); + + steps.iHaveAPercyInstance(); + assertNotNull(PercySteps.getPercy()); + } + + @Test + void testTakeSnapshotWithOptionsTableIncludesRegions() { + initWithMockPercy(); + Map fakeRegion = new HashMap<>(); + fakeRegion.put("algorithm", "ignore"); + when(mockPercy.createRegion(anyMap())).thenReturn(fakeRegion); + + steps.iHaveAPercyInstance(); + steps.iCreateIgnoreRegionCSS(".ad-banner"); + + Map optionsTable = new LinkedHashMap<>(); + optionsTable.put("percyCSS", "body { color: green; }"); + steps.iTakeSnapshotWithOptions("Options+Regions", optionsTable); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); + verify(mockPercy).snapshot(eq("Options+Regions"), captor.capture()); + List> regions = (List>) captor.getValue().get("regions"); + assertNotNull(regions); + assertEquals(1, regions.size()); + assertEquals("body { color: green; }", captor.getValue().get("percyCSS")); + } + + @Test + void testTakeScreenshotWithOptionsTableIncludesRegions() { + initWithMockPercy(); + Map fakeRegion = new HashMap<>(); + fakeRegion.put("algorithm", "ignore"); + when(mockPercy.createRegion(anyMap())).thenReturn(fakeRegion); + + steps.iHaveAPercyInstance(); + steps.iCreateIgnoreRegionCSS(".banner"); + + Map optionsTable = new LinkedHashMap<>(); + optionsTable.put("percyCSS", "body { color: red; }"); + steps.iTakeScreenshotWithOptions("Shot+Regions", optionsTable); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); + verify(mockPercy).screenshot(eq("Shot+Regions"), captor.capture()); + List> regions = (List>) captor.getValue().get("regions"); + assertNotNull(regions); + assertEquals(1, regions.size()); + } + + @Test + void testBuildOptionsParsesScopeOptions() { + initWithMockPercy(); + Map optionsTable = new LinkedHashMap<>(); + optionsTable.put("scopeOptions", "{\"scroll\":true}"); + + steps.iTakeSnapshotWithOptions("Scoped", optionsTable); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); + verify(mockPercy).snapshot(eq("Scoped"), captor.capture()); + Map scopeOptions = (Map) captor.getValue().get("scopeOptions"); + assertNotNull(scopeOptions); + assertEquals(true, scopeOptions.get("scroll")); + } + + @Test + void testGetCucumberVersionReturnsValueWhenManifestPresent() throws Exception { + // Drives the happy path of the version-resolution seam: a non-null + // implementation version is returned verbatim. + String version = invokeGetCucumberVersionWithResolver(() -> "9.9.9"); + assertEquals("9.9.9", version); + } + + @Test + void testGetCucumberVersionFallsBackToUnknownWhenManifestMissing() throws Exception { + // resolver returns null (no manifest) -> "unknown". + String version = invokeGetCucumberVersionWithResolver(() -> null); + assertEquals("unknown", version); + } + + @Test + void testGetCucumberVersionFallsBackToUnknownWhenResolverThrows() throws Exception { + // resolver throws -> the catch block returns "unknown". + String version = invokeGetCucumberVersionWithResolver(() -> { throw new RuntimeException("boom"); }); + assertEquals("unknown", version); + } + + /** + * Invokes the package-private {@code resolveCucumberVersion} seam so the + * happy/null/throwing branches of {@link PercySteps#getCucumberVersion()} + * can be exercised deterministically without depending on the cucumber jar + * manifest (which is absent under test). + */ + private static String invokeGetCucumberVersionWithResolver( + java.util.concurrent.Callable resolver) throws Exception { + java.lang.reflect.Method m = PercySteps.class.getDeclaredMethod( + "resolveCucumberVersion", java.util.concurrent.Callable.class); + m.setAccessible(true); + return (String) m.invoke(null, resolver); + } }