diff --git a/CHANGELOG.md b/CHANGELOG.md index 356efc1..dd4744c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Restore JSON deserialization behaviour. +- Handle text (and similar) input elements which only become visible when interacted with. ## [0.35.0] - 2025-12-22 ### Removed diff --git a/src/main/java/org/zaproxy/zest/core/v1/ZestClientElement.java b/src/main/java/org/zaproxy/zest/core/v1/ZestClientElement.java index 7a76ea5..e74a452 100644 --- a/src/main/java/org/zaproxy/zest/core/v1/ZestClientElement.java +++ b/src/main/java/org/zaproxy/zest/core/v1/ZestClientElement.java @@ -11,6 +11,7 @@ import org.openqa.selenium.support.ui.ExpectedCondition; import org.openqa.selenium.support.ui.ExpectedConditions; import org.openqa.selenium.support.ui.WebDriverWait; +import org.zaproxy.zest.impl.ZestUtils; /** An abstract class representing an action on a client element. */ public abstract class ZestClientElement extends ZestClient { @@ -90,15 +91,24 @@ public WebElement getWebElement(ZestRuntime runtime) throws ZestClientFailExcept if (this.waitForMsec > 0) { WebDriverWait wait = new WebDriverWait(wd, Duration.ofMillis(waitForMsec)); - return wait.until(getExpectedCondition(by)); + try { + return wait.until(getExpectedCondition(by)); + } catch (Exception e) { + // Ignore, as some frameworks keep elements hidden until you act on them + return ZestUtils.withoutImplicitWait(wd, () -> findElement(wd, by)); + } } - return wd.findElement(by); + return findElement(wd, by); } catch (Exception e) { throw new ZestClientFailException(this, e); } } + private WebElement findElement(WebDriver wd, By by) { + return wd.findElement(by); + } + /** * Gets the excepted condition to wait for the element. * diff --git a/src/main/java/org/zaproxy/zest/impl/ZestBasicRunner.java b/src/main/java/org/zaproxy/zest/impl/ZestBasicRunner.java index 8b1f91a..a47dc63 100644 --- a/src/main/java/org/zaproxy/zest/impl/ZestBasicRunner.java +++ b/src/main/java/org/zaproxy/zest/impl/ZestBasicRunner.java @@ -7,8 +7,6 @@ import java.io.IOException; import java.io.Reader; import java.io.Writer; -import java.time.Duration; -import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -617,7 +615,7 @@ public boolean isDebug() { @Override public void addWebDriver(String handle, WebDriver wd) { webDriverMap.put(handle, wd); - wd.manage().timeouts().implicitlyWait(Duration.of(10, ChronoUnit.SECONDS)); + ZestUtils.turnOnImplicitWait(wd); } @Override diff --git a/src/main/java/org/zaproxy/zest/impl/ZestUtils.java b/src/main/java/org/zaproxy/zest/impl/ZestUtils.java index 592d8b5..9893209 100644 --- a/src/main/java/org/zaproxy/zest/impl/ZestUtils.java +++ b/src/main/java/org/zaproxy/zest/impl/ZestUtils.java @@ -3,15 +3,21 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ package org.zaproxy.zest.impl; +import java.time.Duration; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; +import java.util.function.Supplier; import net.htmlparser.jericho.Element; import net.htmlparser.jericho.HTMLElementName; import net.htmlparser.jericho.Source; +import org.openqa.selenium.WebDriver; import org.zaproxy.zest.core.v1.ZestResponse; public class ZestUtils { + private static final int DEFAULT_IMPLICIT_WAIT_SECONDS = 10; + public static List getForms(ZestResponse response) { List list = new ArrayList<>(); Source src = new Source(response.getHeaders() + response.getBody()); @@ -49,4 +55,23 @@ public static List getFields(ZestResponse response, int formId) { } return list; } + + public static void turnOffImplicitWait(WebDriver wd) { + wd.manage().timeouts().implicitlyWait(Duration.of(0, ChronoUnit.SECONDS)); + } + + public static void turnOnImplicitWait(WebDriver wd) { + wd.manage() + .timeouts() + .implicitlyWait(Duration.of(DEFAULT_IMPLICIT_WAIT_SECONDS, ChronoUnit.SECONDS)); + } + + public static T withoutImplicitWait(WebDriver wd, Supplier function) { + turnOffImplicitWait(wd); + try { + return function.get(); + } finally { + turnOnImplicitWait(wd); + } + } } diff --git a/src/test/java/org/zaproxy/zest/test/v1/ZestClientElementSubmitUnitTest.java b/src/test/java/org/zaproxy/zest/test/v1/ZestClientElementSubmitUnitTest.java index d364cee..a563a77 100644 --- a/src/test/java/org/zaproxy/zest/test/v1/ZestClientElementSubmitUnitTest.java +++ b/src/test/java/org/zaproxy/zest/test/v1/ZestClientElementSubmitUnitTest.java @@ -5,6 +5,7 @@ import static fi.iki.elonen.NanoHTTPD.newFixedLengthResponse; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -226,12 +227,13 @@ protected Response serve(IHTTPSession session) { ZestClientFailException ex = assertThrows(ZestClientFailException.class, () -> runner.run(script, null)); assertThat(ex) - .hasMessageContaining( - "Expected condition failed: waiting for element found by By.id: test-submit to be clickable"); + .message() + .contains("org.openqa.selenium.NoSuchElementException: Unable to locate element:") + .contains("test-submit"); } @Test - void shouldFailIfElementEnabledAfterWaiting() { + void shouldSubmitDisabledElementWhenClickableWaitFailsButElementInDom() throws Exception { // Given server.addHandler( new NanoServerHandler(PATH_SERVER_FILE) { @@ -249,25 +251,13 @@ protected Response serve(IHTTPSession session) { }); ZestScript script = new ZestScript(); runner = new ZestBasicRunner(); - TestClientLaunch clientLaunch = - new TestClientLaunch( - "windowHandle", - """ - setTimeout(() => { - document.getElementById("test-submit").disabled = false; - }, "10000"); - """); - script.add(clientLaunch); + script.add(new ZestClientLaunch("windowHandle", "firefox", getServerUrl(PATH_SERVER_FILE))); script.add( newZestClientElementSubmit( "windowHandle", "id", "test-submit", (int) TimeUnit.SECONDS.toMillis(5))); // When / Then - ZestClientFailException ex = - assertThrows(ZestClientFailException.class, () -> runner.run(script, null)); - assertThat(ex) - .hasMessageContaining( - "Expected condition failed: waiting for element found by By.id: test-submit to be clickable"); + assertDoesNotThrow(() -> runner.run(script, null)); } @Test diff --git a/src/test/java/org/zaproxy/zest/test/v1/ZestClientElementUnitTest.java b/src/test/java/org/zaproxy/zest/test/v1/ZestClientElementUnitTest.java new file mode 100644 index 0000000..f40ac60 --- /dev/null +++ b/src/test/java/org/zaproxy/zest/test/v1/ZestClientElementUnitTest.java @@ -0,0 +1,268 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +package org.zaproxy.zest.test.v1; + +import static fi.iki.elonen.NanoHTTPD.newFixedLengthResponse; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import fi.iki.elonen.NanoHTTPD.IHTTPSession; +import fi.iki.elonen.NanoHTTPD.Response; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.By; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.support.ui.ExpectedCondition; +import org.zaproxy.zest.core.v1.ZestClientElementAssign; +import org.zaproxy.zest.core.v1.ZestClientElementClear; +import org.zaproxy.zest.core.v1.ZestClientElementSendKeys; +import org.zaproxy.zest.core.v1.ZestClientFailException; +import org.zaproxy.zest.core.v1.ZestScript; +import org.zaproxy.zest.impl.ZestBasicRunner; +import org.zaproxy.zest.testutils.NanoServerHandler; + +/** Unit tests for {@link org.zaproxy.zest.core.v1.ZestClientElement}. */ +class ZestClientElementUnitTest extends ClientBasedTest { + + private static final String WINDOW_HANDLE = "windowHandle"; + private static final String PATH_SERVER_FILE = "/test.html"; + + private static final int WAIT_FOR_MSEC = (int) TimeUnit.MILLISECONDS.toMillis(500); + + private static final String VISIBLE_INPUT_HTML = + """ + + + + """ + .strip(); + + /** + * Ionic-style hidden native input: still interactable after {@code findElement}, but must not + * be used to prove a wait timed out because {@code opacity: 0} may still satisfy some waits. + */ + private static final String OPACITY_ZERO_INPUT_HTML = + """ + + + + """ + .strip(); + + /** Fails Selenium visibility and clickability waits reliably (unlike {@code opacity: 0}). */ + private static final String VISIBILITY_HIDDEN_INPUT_HTML = + """ + + + + """ + .strip(); + + @Test + void shouldUseFindElementAfterVisibilityWaitTimesOut() throws Exception { + // Given + serveHtml(VISIBLE_INPUT_HTML); + ZestScript script = scriptWithLaunch(); + ZestClientElementClear clear = + new ZestClientElementClearWithUnmetVisibilityWait(WINDOW_HANDLE, "id", "username"); + clear.setWaitForMsec(WAIT_FOR_MSEC); + script.add(clear); + script.add(assignValue("username.value", "value")); + + // When + long timeTakenMs = runScript(script); + + // Then + assertThat(runner.getVariable("username.value")).isEqualTo(""); + assertWaitTimedOut(timeTakenMs, "visibility"); + } + + @Test + void shouldUseFindElementAfterClickableWaitTimesOut() throws Exception { + // Given + serveHtml( + """ + + + + """ + .strip()); + ZestScript script = scriptWithLaunch(); + ZestClientElementSendKeys sendKeys = + new ZestClientElementSendKeysWithUnmetClickableWait( + WINDOW_HANDLE, "id", "username", "test-user"); + sendKeys.setWaitForMsec(WAIT_FOR_MSEC); + script.add(sendKeys); + script.add(assignValue("username.value", "value")); + + // When + long timeTakenMs = runScript(script); + + // Then + assertThat(runner.getVariable("username.value")).isEqualTo("test-user"); + assertWaitTimedOut(timeTakenMs, "clickable"); + } + + @Test + void shouldReadValueAfterRealVisibilityWaitTimesOut() throws Exception { + // Given + serveHtml(VISIBILITY_HIDDEN_INPUT_HTML); + ZestScript script = scriptWithLaunch(); + ZestClientElementAssign assign = assignValue("username.value", "value"); + assign.setWaitForMsec(WAIT_FOR_MSEC); + script.add(assign); + + // When + long timeTakenMs = runScript(script); + + // Then + assertThat(runner.getVariable("username.value")).isEqualTo("secret"); + assertWaitTimedOut(timeTakenMs, "visibility (visibility: hidden)"); + } + + @Test + void shouldClearIonicStyleOpacityZeroInputAfterVisibilityWaitTimesOut() throws Exception { + // Given + serveHtml(OPACITY_ZERO_INPUT_HTML); + ZestScript script = scriptWithLaunch(); + ZestClientElementClear clear = new ZestClientElementClear(WINDOW_HANDLE, "id", "username"); + clear.setWaitForMsec(WAIT_FOR_MSEC); + script.add(clear); + script.add(assignValue("username.value", "value")); + + // When + long timeTakenMs = runScript(script); + + // Then + assertThat(runner.getVariable("username.value")).isEqualTo(""); + assertWaitTimedOut(timeTakenMs, "visibility (opacity: 0)"); + } + + @Test + void shouldFailWhenElementNotInDomAfterWaitTimesOut() { + // Given + serveHtml(""); + ZestScript script = scriptWithLaunch(); + ZestClientElementClear clear = new ZestClientElementClear(WINDOW_HANDLE, "id", "missing"); + clear.setWaitForMsec(WAIT_FOR_MSEC); + script.add(clear); + + // When + long startTime = System.currentTimeMillis(); + ZestClientFailException ex = + assertThrows(ZestClientFailException.class, () -> runner.run(script, null)); + long timeTakenMs = System.currentTimeMillis() - startTime; + + // Then + assertThat(ex).hasMessageContaining("Unable to locate element: #missing"); + assertWaitTimedOut(timeTakenMs, "visibility"); + } + + @Test + void shouldClearVisibleInputQuicklyWhenVisibilityWaitSucceeds() throws Exception { + // Given + serveHtml(VISIBLE_INPUT_HTML); + ZestScript script = scriptWithLaunch(); + ZestClientElementClear clear = new ZestClientElementClear(WINDOW_HANDLE, "id", "username"); + clear.setWaitForMsec((int) TimeUnit.MINUTES.toMillis(2)); + script.add(clear); + script.add(assignValue("username.value", "value")); + + // When + long timeTakenMs = runScript(script); + + // Then + assertThat(runner.getVariable("username.value")).isEqualTo(""); + assertTrue( + timeTakenMs < TimeUnit.SECONDS.toMillis(10), + "Expected visibility wait to succeed without waiting the full timeout: " + + timeTakenMs); + } + + private void serveHtml(String html) { + server.addHandler( + new NanoServerHandler(PATH_SERVER_FILE) { + @Override + protected Response serve(IHTTPSession session) { + return newFixedLengthResponse(html); + } + }); + } + + private ZestScript scriptWithLaunch() { + ZestScript script = new ZestScript(); + runner = new ZestBasicRunner(); + script.add(new TestClientLaunch(WINDOW_HANDLE, PATH_SERVER_FILE)); + return script; + } + + private static ZestClientElementAssign assignValue(String variableName, String attribute) { + return new ZestClientElementAssign( + variableName, WINDOW_HANDLE, "id", "username", attribute); + } + + private long runScript(ZestScript script) throws Exception { + long startTime = System.currentTimeMillis(); + runner.run(script, null); + return System.currentTimeMillis() - startTime; + } + + private static void assertWaitTimedOut(long timeTakenMs, String waitDescription) { + assertTrue( + timeTakenMs >= WAIT_FOR_MSEC, + "Expected " + + waitDescription + + " wait to time out before findElement fallback: " + + timeTakenMs); + } + + /** + * Uses the same visibility condition as {@link ZestClientElementClear}, but forces the wait to + * expire so the test proves {@code findElement} is used afterwards. + */ + private static final class ZestClientElementClearWithUnmetVisibilityWait + extends ZestClientElementClear { + + ZestClientElementClearWithUnmetVisibilityWait( + String windowHandle, String type, String element) { + super(windowHandle, type, element); + } + + @Override + protected ExpectedCondition getExpectedCondition(By by) { + return unmetCondition(super.getExpectedCondition(by)); + } + } + + /** + * Uses the same clickability condition as {@link ZestClientElementSendKeys}, but forces the + * wait to expire so the test proves {@code findElement} is used afterwards. + */ + private static final class ZestClientElementSendKeysWithUnmetClickableWait + extends ZestClientElementSendKeys { + + ZestClientElementSendKeysWithUnmetClickableWait( + String windowHandle, String type, String element, String value) { + super(windowHandle, type, element, value); + } + + @Override + protected ExpectedCondition getExpectedCondition(By by) { + return unmetCondition(super.getExpectedCondition(by)); + } + } + + private static ExpectedCondition unmetCondition( + ExpectedCondition delegate) { + return new ExpectedCondition<>() { + @Override + public WebElement apply(WebDriver driver) { + delegate.apply(driver); + return null; + } + }; + } +}