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();
+ }
+}