From ab78f5177309f2308bb0a44a6d5a605afa365dfc Mon Sep 17 00:00:00 2001 From: Mohammad Saddam Hossain Date: Sat, 3 Oct 2026 23:12:58 +0530 Subject: [PATCH] feat(basepage): add withNewWindow/withNewTab with guaranteed window restore (#49) Co-Authored-By: Claude Sonnet 5.5 --- docs-site/docs/recipes/handle-new-windows.md | 23 ++++ docs-site/sidebars.js | 1 + .../java/com/seleniumboot/test/BasePage.java | 53 +++++++++ .../unit/BasePageNewWindowTest.java | 112 ++++++++++++++++++ 4 files changed, 189 insertions(+) create mode 100644 docs-site/docs/recipes/handle-new-windows.md create mode 100644 src/test/java/com/seleniumboot/unit/BasePageNewWindowTest.java diff --git a/docs-site/docs/recipes/handle-new-windows.md b/docs-site/docs/recipes/handle-new-windows.md new file mode 100644 index 0000000..40880c3 --- /dev/null +++ b/docs-site/docs/recipes/handle-new-windows.md @@ -0,0 +1,23 @@ +--- +description: "Test a link that opens in a new tab without hand-rolling window handles: withNewWindow switches to it, runs your checks, closes it and restores the original window." +id: handle-new-windows +title: Handle new windows and tabs +sidebar_label: Handle new windows and tabs +--- + +# Handle new windows and tabs + +A link with `target="_blank"` opens a new tab, and every later driver call still targets the old one until you switch. `BasePage`'s `withNewWindow(opener, body)` does the handle bookkeeping: it runs `opener` (the click), switches to the window that opened, runs `body` there, closes it, and switches back to the original window — even if `body` throws. + +```java title="FooterPage.java" +public void verifyTermsOpensInNewTab() { + withNewWindow( + () -> click(By.linkText("Terms")), + () -> assertEquals(driver.getTitle(), "Terms of Service")); + // back on the original window here +} +``` + +`withNewTab(opener, body)` is an alias — browsers open both the same way. + +If no new window appears within `timeouts.explicit`, a `TimeoutException` is thrown and `body` does not run, so a link that silently fails to open fails the test rather than passing it. diff --git a/docs-site/sidebars.js b/docs-site/sidebars.js index 7a5166a..88a1d9b 100644 --- a/docs-site/sidebars.js +++ b/docs-site/sidebars.js @@ -61,6 +61,7 @@ const sidebars = { 'recipes/upload-a-file', 'recipes/download-and-verify-a-pdf', 'recipes/handle-iframes', + 'recipes/handle-new-windows', 'recipes/handle-shadow-dom', ], }, diff --git a/src/main/java/com/seleniumboot/test/BasePage.java b/src/main/java/com/seleniumboot/test/BasePage.java index 13fcf13..74443b6 100644 --- a/src/main/java/com/seleniumboot/test/BasePage.java +++ b/src/main/java/com/seleniumboot/test/BasePage.java @@ -30,6 +30,8 @@ import java.io.File; import java.nio.file.Paths; import java.time.Duration; +import java.util.HashSet; +import java.util.Set; /** * Base class for all page objects. @@ -333,6 +335,57 @@ protected WebElement smartFind(By primary, By... fallbacks) { return SmartLocator.find(driver, all); } + // ---------------------------------------------------------- + // Window / tab helpers + // ---------------------------------------------------------- + + /** + * Runs {@code opener} (the click that opens a new window or tab), switches to the window it + * opened, runs {@code body} there, then closes that window and switches back to the original. + * The original window is restored even if {@code opener} or {@code body} throws. + * + *
+     * withNewWindow(() -> click(By.linkText("Terms")), () -> {
+     *     assertEquals(driver.getTitle(), "Terms of Service");
+     * });
+     * // back on the original window here
+     * 
+ * + * @throws org.openqa.selenium.TimeoutException if no new window appears within + * {@code timeouts.explicit} — the body is not run, so a missing window fails the test + * instead of passing it silently + */ + protected void withNewWindow(Runnable opener, Runnable body) { + String original = driver.getWindowHandle(); + Set before = new HashSet<>(driver.getWindowHandles()); + String opened = null; + try { + opener.run(); + int timeout = SeleniumBootContext.getConfig().getTimeouts().getExplicit(); + opened = new WebDriverWait(driver, Duration.ofSeconds(timeout)).until(d -> { + Set added = new HashSet<>(d.getWindowHandles()); + added.removeAll(before); + return added.isEmpty() ? null : added.iterator().next(); + }); + driver.switchTo().window(opened); + body.run(); + } finally { + try { + if (opened != null && driver.getWindowHandles().contains(opened)) { + driver.switchTo().window(opened); + driver.close(); + } + } finally { + driver.switchTo().window(original); + } + } + } + + /** Alias for {@link #withNewWindow(Runnable, Runnable)} — browsers open both the same way. */ + protected void withNewTab(Runnable opener, Runnable body) { + withNewWindow(opener, body); + } + // ---------------------------------------------------------- // iFrame helpers // ---------------------------------------------------------- diff --git a/src/test/java/com/seleniumboot/unit/BasePageNewWindowTest.java b/src/test/java/com/seleniumboot/unit/BasePageNewWindowTest.java new file mode 100644 index 0000000..43b8183 --- /dev/null +++ b/src/test/java/com/seleniumboot/unit/BasePageNewWindowTest.java @@ -0,0 +1,112 @@ +package com.seleniumboot.unit; + +import com.seleniumboot.config.SeleniumBootConfig; +import com.seleniumboot.internal.SeleniumBootContext; +import com.seleniumboot.test.BasePage; +import org.mockito.MockedStatic; +import org.openqa.selenium.TimeoutException; +import org.openqa.selenium.WebDriver; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.mockito.Mockito.*; +import static org.testng.Assert.*; + +/** {@code withNewWindow} / {@code withNewTab}: switch to the opened window, always restore the original. */ +public class BasePageNewWindowTest { + + private WebDriver driver; + private WebDriver.TargetLocator target; + private MockedStatic contextMock; + private Page page; + + private static final class Page extends BasePage { + Page(WebDriver d) { super(d); } + void window(Runnable o, Runnable b) { withNewWindow(o, b); } + void tab(Runnable o, Runnable b) { withNewTab(o, b); } + } + + @BeforeMethod + public void setup() { + driver = mock(WebDriver.class); + target = mock(WebDriver.TargetLocator.class); + when(driver.switchTo()).thenReturn(target); + when(driver.getWindowHandle()).thenReturn("main"); + when(driver.getWindowHandles()).thenReturn(Set.of("main")); + + SeleniumBootConfig.Timeouts timeouts = new SeleniumBootConfig.Timeouts(); + timeouts.setExplicit(1); + SeleniumBootConfig config = new SeleniumBootConfig(); + config.setTimeouts(timeouts); + contextMock = mockStatic(SeleniumBootContext.class); + contextMock.when(SeleniumBootContext::getConfig).thenReturn(config); + page = new Page(driver); + } + + @AfterMethod + public void teardown() { contextMock.close(); } + + private void openingWindowAddsHandle() { + when(driver.getWindowHandles()).thenReturn(Set.of("main")).thenReturn(new LinkedHashSet<>(List.of("main", "popup"))); + } + + @Test + public void newWindow_switchesRunsBodyClosesItAndRestoresOriginal() { + openingWindowAddsHandle(); + AtomicBoolean ran = new AtomicBoolean(); + + page.window(() -> {}, () -> { + ran.set(true); + verify(target, atLeastOnce()).window("popup"); + }); + + assertTrue(ran.get()); + verify(driver).close(); + verify(target, atLeastOnce()).window("main"); + } + + @Test + public void withNewTab_behavesTheSame() { + openingWindowAddsHandle(); + page.tab(() -> {}, () -> {}); + verify(target, atLeastOnce()).window("popup"); + verify(target, atLeastOnce()).window("main"); + } + + @Test + public void noNewWindow_failsLoudlyWithoutRunningBodyAndStaysOnOriginal() { + AtomicBoolean ran = new AtomicBoolean(); + + assertThrows(TimeoutException.class, () -> page.window(() -> {}, () -> ran.set(true))); + + assertFalse(ran.get()); + verify(driver, never()).close(); + verify(target).window("main"); + } + + @Test + public void bodyThrows_stillClosesNewWindowAndRestoresOriginal() { + openingWindowAddsHandle(); + + assertThrows(IllegalStateException.class, + () -> page.window(() -> {}, () -> { throw new IllegalStateException("boom"); })); + + verify(driver).close(); + verify(target, atLeastOnce()).window("main"); + } + + @Test + public void openerThrows_restoresOriginal() { + assertThrows(IllegalStateException.class, + () -> page.window(() -> { throw new IllegalStateException("click failed"); }, () -> {})); + + verify(target).window("main"); + verify(driver, never()).close(); + } +}