diff --git a/CHANGELOG.md b/CHANGELOG.md index f38cec1..547420a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - **Fix: `@PreCondition` session restore on a fresh browser** — the cached cookies and localStorage were silently dropped when the next test started on `about:blank`, so only the first test per thread was logged in. Restore now navigates to the origin the session was captured on first. +- **LambdaTest cloud provider** — `execution.mode: lambdatest` now creates W3C + `LT:Options` sessions, supports environment-based credentials and capability + overrides, and links each cloud session from the HTML report. (#197) + +- **Cloud execution mode validation** — `execution.mode` now accepts the + existing `browserstack` and `saucelabs` providers, alongside `local`, + `remote`, and the new `lambdatest` provider. + - **Trace shows browser console errors** — when `browser.captureConsoleErrors: true`, the JS errors collected for a test now appear in its trace file under "Console Errors", next to the steps and failure details, so a shared trace includes what the browser logged. No change when the option is off. (#130) - **Network mocking on Firefox via WebDriver BiDi** — `networkMock().stub(...)` now uses WebDriver BiDi network interception under the hood when supported by the driver (including Firefox and BiDi-enabled drivers), falling back to Chrome DevTools Protocol (CDP) for Chromium-based drivers. The existing stubbing API (`returnJson`, `returnBody`, `returnStatus`, `delay`, `clear`) works identically across Chrome and Firefox. (#196) diff --git a/docs-site/docs/changelog.md b/docs-site/docs/changelog.md index 1a5c809..1b41e2c 100644 --- a/docs-site/docs/changelog.md +++ b/docs-site/docs/changelog.md @@ -11,6 +11,14 @@ All notable changes to Selenium Boot are documented here. ## [Unreleased] +- **LambdaTest cloud provider** — `execution.mode: lambdatest` now creates W3C + `LT:Options` sessions, supports environment-based credentials and capability + overrides, and links each cloud session from the HTML report. (#197) + +- **Cloud execution mode validation** — `execution.mode` now accepts the + existing `browserstack` and `saucelabs` providers, alongside `local`, + `remote`, and the new `lambdatest` provider. + - **Trace shows browser console errors** — when `browser.captureConsoleErrors: true`, the JS errors collected for a test now appear in its trace file under "Console Errors", next to the steps and failure details, so a shared trace includes what the browser logged. No change when the option is off. (#130) - **Network mocking on Firefox via WebDriver BiDi** — `networkMock().stub(...)` now uses WebDriver BiDi network interception under the hood when supported by the driver (including Firefox and BiDi-enabled drivers), falling back to Chrome DevTools Protocol (CDP) for Chromium-based drivers. The existing stubbing API (`returnJson`, `returnBody`, `returnStatus`, `delay`, `clear`) works identically across Chrome and Firefox. (#196) diff --git a/docs-site/docs/cloud-execution.md b/docs-site/docs/cloud-execution.md index 41599de..2690104 100644 --- a/docs-site/docs/cloud-execution.md +++ b/docs-site/docs/cloud-execution.md @@ -1,5 +1,5 @@ --- -description: "Run Selenium tests on BrowserStack and Sauce Labs with zero code changes. Switch from local Chrome to cloud browsers by editing one config line." +description: "Run Selenium tests on BrowserStack, Sauce Labs, and LambdaTest with zero code changes. Switch from local Chrome to cloud browsers by editing one config line." id: cloud-execution title: Cloud Execution sidebar_position: 12 @@ -7,10 +7,10 @@ sidebar_position: 12 # Cloud Execution -Selenium Boot supports running your test suite on **BrowserStack** and **Sauce Labs** with zero test-code changes. Switch from local Chrome to a cloud browser farm by changing one line in `selenium-boot.yml`. +Selenium Boot supports running your test suite on **BrowserStack**, **Sauce Labs**, and **LambdaTest** with zero test-code changes. Switch from local Chrome to a cloud browser farm by changing one line in `selenium-boot.yml`. :::caution Requires Selenium Boot 3.2.1+ -Cloud execution (`execution.mode: browserstack` or `saucelabs`) requires **Selenium Boot 3.2.1 or later**. It does not work in any earlier release — config loading rejects any `execution.mode` other than `local` or `remote` at startup — regardless of what the examples on this page show. +Cloud execution (`execution.mode: browserstack`, `saucelabs`, or `lambdatest`) requires **Selenium Boot 3.2.1 or later**. ::: --- @@ -25,6 +25,7 @@ All four execution modes share the same driver lifecycle — tests use `getDrive | `remote` | Your own Selenium Grid / Selenoid / Moon | | `browserstack` | BrowserStack Automate cloud | | `saucelabs` | Sauce Labs cloud | +| `lambdatest` | LambdaTest cloud | --- @@ -242,6 +243,64 @@ Ensure your BrowserStack / Sauce Labs plan allows the number of concurrent sessi --- +## LambdaTest + +### Prerequisites + +1. Sign up at [lambdatest.com](https://www.lambdatest.com) +2. Copy your LambdaTest username and access key from your account settings + +### Config + +```yaml title="selenium-boot.yml" +execution: + mode: lambdatest + lambdatest: + username: ${LT_USERNAME} + accessKey: ${LT_ACCESS_KEY} + platform: "Windows 11" + browser: chrome + browserVersion: latest + +browser: + name: chrome + +timeouts: + explicit: 10 + pageLoad: 30 +``` + +The provider uses LambdaTest's W3C `LT:Options` capability and the +`https://hub.lambdatest.com/wd/hub` hub URL. Keys under `capabilities` are +merged into `LT:Options`, so they override the generated defaults: + +```yaml +execution: + mode: lambdatest + lambdatest: + username: ${LT_USERNAME} + accessKey: ${LT_ACCESS_KEY} + platform: "Windows 11" + browser: chrome + browserVersion: latest + capabilities: + build: "v2.1.0" + video: true +``` + +Set environment variables before running: + +```bash +export LT_USERNAME=your_username +export LT_ACCESS_KEY=your_access_key +mvn test +``` + +After each test, the HTML report includes a **☁ View Session** link to the +LambdaTest session logs. + +--- + ## Switching between environments Use Maven profiles or environment variables to switch execution targets without changing any YAML file: @@ -265,7 +324,7 @@ Keep separate YAML files per environment — each imports shared settings and on ```yaml title="selenium-boot.yml" execution: - mode: browserstack # local | remote | browserstack | saucelabs + mode: browserstack # local | remote | browserstack | saucelabs | lambdatest # ── BrowserStack ─────────────────────────────────────────────── browserstack: @@ -292,6 +351,16 @@ execution: capabilities: # raw sauce:options overrides recordVideo: true + # ── LambdaTest ───────────────────────────────────────────────── + lambdatest: + username: ${LT_USERNAME} + accessKey: ${LT_ACCESS_KEY} + platform: "Windows 11" + browser: chrome + browserVersion: latest + capabilities: # raw LT:Options overrides + video: true + browser: name: chrome diff --git a/docs-site/docs/configuration.md b/docs-site/docs/configuration.md index e24fa5d..3119b37 100644 --- a/docs-site/docs/configuration.md +++ b/docs-site/docs/configuration.md @@ -62,7 +62,7 @@ browser: # ── Execution ─────────────────────────────────────────────────────────────── execution: - mode: local # local | remote | browserstack | saucelabs + mode: local # local | remote | browserstack | saucelabs | lambdatest baseUrl: https://your-app.com gridUrl: http://localhost:4444 # only for remote mode diff --git a/docs-site/docs/intro.md b/docs-site/docs/intro.md index 5293d79..61c83f0 100644 --- a/docs-site/docs/intro.md +++ b/docs-site/docs/intro.md @@ -118,7 +118,7 @@ Outcomes first — the API that delivers each one is on the right so you can jum | **Verify what actually landed in the DB** | `db().assertRowExists()`, `db().query().assertValue()` — plain JDBC, no ORM | | **Assert on the email your app sent** | `mailbox().waitForEmail(to("user@test.com"))` — Mailhog, Mailtrap, Outlook, IMAP | | **Skip the browser for non-UI tests** | `@NoBrowser` — DB assertions, API checks, file operations, no WebDriver | -| **Run on real cloud browsers unchanged** | `execution.mode: browserstack` / `saucelabs` — session URL in HTML report | +| **Run on real cloud browsers unchanged** | `execution.mode: browserstack` / `saucelabs` / `lambdatest` — session URL in HTML report | | **Understand failures without digging** | AI failure analysis — Claude explains why a test failed and suggests a fix | | **Locators that repair themselves** | Self-healing fallback strategies when a locator fails | | **Know which tests will flake next** | Flakiness prediction — risk scores from run history, radar chart in report | diff --git a/docs-site/docs/quarantine.md b/docs-site/docs/quarantine.md index 3dc48b3..c093596 100644 --- a/docs-site/docs/quarantine.md +++ b/docs-site/docs/quarantine.md @@ -43,7 +43,7 @@ A committed YAML file solves this cleanly: | Survives fresh clones | ✅ — it's in git | | No external services needed | ✅ — just a YAML file | | Auditable | ✅ — changes go through PRs | -| Works with all execution modes | ✅ — local, remote, BrowserStack, Sauce Labs | +| Works with all execution modes | ✅ — local, remote, BrowserStack, Sauce Labs, LambdaTest | | Works across team members | ✅ — everyone gets the same list | --- diff --git a/src/main/java/com/seleniumboot/config/ConfigurationLoader.java b/src/main/java/com/seleniumboot/config/ConfigurationLoader.java index f0a0013..93146bd 100644 --- a/src/main/java/com/seleniumboot/config/ConfigurationLoader.java +++ b/src/main/java/com/seleniumboot/config/ConfigurationLoader.java @@ -163,9 +163,14 @@ private static void validate(SeleniumBootConfig config) { } String mode = config.getExecution().getMode(); - if (!"local".equalsIgnoreCase(mode) && !"remote".equalsIgnoreCase(mode)) { + if (!"local".equalsIgnoreCase(mode) + && !"remote".equalsIgnoreCase(mode) + && !"browserstack".equalsIgnoreCase(mode) + && !"saucelabs".equalsIgnoreCase(mode) + && !"lambdatest".equalsIgnoreCase(mode)) { throw new IllegalStateException( - "execution.mode must be 'local' or 'remote', got: '" + mode + "'"); + "execution.mode must be 'local', 'remote', 'browserstack', " + + "'saucelabs', or 'lambdatest', got: '" + mode + "'"); } if (config.getTimeouts() == null diff --git a/src/main/java/com/seleniumboot/config/SeleniumBootConfig.java b/src/main/java/com/seleniumboot/config/SeleniumBootConfig.java index 0130d65..38ca6ec 100644 --- a/src/main/java/com/seleniumboot/config/SeleniumBootConfig.java +++ b/src/main/java/com/seleniumboot/config/SeleniumBootConfig.java @@ -226,6 +226,10 @@ public void setMaxActiveSessions(int maxActiveSessions) { private BrowserStack browserstack = new BrowserStack(); private SauceLabs saucelabs = new SauceLabs(); + private LambdaTest lambdatest = new LambdaTest(); + + public LambdaTest getLambdatest() {return lambdatest;} + public void setLambdatest(LambdaTest v) {this.lambdatest = v;} public BrowserStack getBrowserstack() { return browserstack; } public void setBrowserstack(BrowserStack v) { this.browserstack = v; } @@ -287,6 +291,28 @@ public static final class SauceLabs { public java.util.Map getCapabilities() { return capabilities; } public void setCapabilities(java.util.Map v) { this.capabilities = v != null ? v : new java.util.LinkedHashMap<>(); } } + + public static final class LambdaTest { + private String username; + private String accessKey; + private String platform = "Windows 11"; + private String browser = "chrome"; + private String browserVersion = "latest"; + private java.util.Map capabilities = new java.util.LinkedHashMap<>(); + + public String getUsername() { return username; } + public void setUsername(String v) { this.username = v; } + public String getAccessKey() { return accessKey; } + public void setAccessKey(String v) { this.accessKey = v; } + public String getPlatform() { return platform; } + public void setPlatform(String v) { this.platform = v; } + public String getBrowser() { return browser; } + public void setBrowser(String v) { this.browser = v; } + public String getBrowserVersion() { return browserVersion; } + public void setBrowserVersion(String v){ this.browserVersion = v; } + public java.util.Map getCapabilities() { return capabilities; } + public void setCapabilities(java.util.Map v) { this.capabilities = v != null ? v : new java.util.LinkedHashMap<>(); } + } } public static final class Retry { diff --git a/src/main/java/com/seleniumboot/driver/DriverManager.java b/src/main/java/com/seleniumboot/driver/DriverManager.java index 438e426..18e46e0 100644 --- a/src/main/java/com/seleniumboot/driver/DriverManager.java +++ b/src/main/java/com/seleniumboot/driver/DriverManager.java @@ -132,7 +132,7 @@ public static void createDriver() { DRIVER.set(driver); - // Capture cloud session URL for BrowserStack / Sauce Labs + // Capture cloud session URL for BrowserStack / Sauce Labs / LambdaTest try { String mode = SeleniumBootContext.getConfig().getExecution().getMode(); if (driver instanceof org.openqa.selenium.remote.RemoteWebDriver rdw) { @@ -141,6 +141,8 @@ public static void createDriver() { CLOUD_SESSION_URL.set(BrowserStackProvider.SESSION_URL_PREFIX + sessionId); } else if ("saucelabs".equalsIgnoreCase(mode)) { CLOUD_SESSION_URL.set(SauceLabsProvider.SESSION_URL_PREFIX + sessionId); + } else if ("lambdatest".equalsIgnoreCase(mode)) { + CLOUD_SESSION_URL.set(LambdaTestProvider.SESSION_URL_PREFIX + sessionId); } } } catch (Exception ignored) {} diff --git a/src/main/java/com/seleniumboot/driver/DriverProviderFactory.java b/src/main/java/com/seleniumboot/driver/DriverProviderFactory.java index 6ed28c6..d7144de 100644 --- a/src/main/java/com/seleniumboot/driver/DriverProviderFactory.java +++ b/src/main/java/com/seleniumboot/driver/DriverProviderFactory.java @@ -28,6 +28,10 @@ public static DriverProvider getProvider() { return new SauceLabsProvider(); } + if ("lambdatest".equalsIgnoreCase(executionMode)) { + return new LambdaTestProvider(); + } + // Custom providers registered via SPI or programmatically take precedence DriverProvider custom = DriverProviderRegistry.find(browser); if (custom != null) { diff --git a/src/main/java/com/seleniumboot/driver/LambdaTestProvider.java b/src/main/java/com/seleniumboot/driver/LambdaTestProvider.java new file mode 100644 index 0000000..f754f95 --- /dev/null +++ b/src/main/java/com/seleniumboot/driver/LambdaTestProvider.java @@ -0,0 +1,125 @@ +package com.seleniumboot.driver; + +import com.seleniumboot.config.SeleniumBootConfig; +import com.seleniumboot.internal.SeleniumBootContext; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.chrome.ChromeOptions; +import org.openqa.selenium.edge.EdgeOptions; +import org.openqa.selenium.firefox.FirefoxOptions; +import org.openqa.selenium.remote.AbstractDriverOptions; +import org.openqa.selenium.remote.RemoteWebDriver; +import org.openqa.selenium.safari.SafariOptions; + +import java.net.URL; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Creates a RemoteWebDriver session against LambdaTest. + */ +public class LambdaTestProvider implements DriverProvider { + + public static final String HUB_URL + = "https://hub.lambdatest.com/wd/hub"; + + public static final String SESSION_URL_PREFIX + = "https://automation.lambdatest.com/logs/?sessionID="; + + @Override + public WebDriver createDriver() { + SeleniumBootConfig cfg = SeleniumBootContext.getConfig(); + SeleniumBootConfig.Execution.LambdaTest lt + = cfg.getExecution().getLambdatest(); + + String username = BrowserStackProvider.resolveEnv(lt.getUsername()); + String accessKey = BrowserStackProvider.resolveEnv(lt.getAccessKey()); + String browser = lt.getBrowser() != null + ? lt.getBrowser() : "chrome"; + String testId = SeleniumBootContext.getCurrentTestId(); + + if (isBlank(username)) { + throw new IllegalStateException( + "[LambdaTest] execution.lambdatest.username is required"); + } + + if (isBlank(accessKey)) { + throw new IllegalStateException( + "[LambdaTest] execution.lambdatest.accessKey is required"); + } + + Map ltOptions = new LinkedHashMap<>(); + ltOptions.put("username", username); + ltOptions.put("accessKey", accessKey); + + if (!isBlank(testId)) { + ltOptions.put("name", testId); + } + + // User-provided LT:Options values override defaults. + if (lt.getCapabilities() != null) { + ltOptions.putAll(lt.getCapabilities()); + } + + AbstractDriverOptions options = buildOptions( + browser, + lt.getBrowserVersion(), + lt.getPlatform(), + ltOptions + ); + + try { + WebDriver driver + = new RemoteWebDriver(new URL(HUB_URL), options); + + driver.manage().timeouts().implicitlyWait(Duration.ZERO); + driver.manage().timeouts().pageLoadTimeout( + Duration.ofSeconds(cfg.getTimeouts().getPageLoad())); + + return driver; + } catch (Exception e) { + throw new IllegalStateException( + "[LambdaTest] Failed to create session: " + + e.getMessage(), e); + } + } + + private static AbstractDriverOptions buildOptions( + String browser, + String browserVersion, + String platform, + Map ltOptions) { + + AbstractDriverOptions options; + + switch (browser.toLowerCase()) { + case "firefox": + options = new FirefoxOptions(); + break; + case "edge": + options = new EdgeOptions(); + break; + case "safari": + options = new SafariOptions(); + break; + default: + options = new ChromeOptions(); + break; + } + + if (!isBlank(browserVersion)) { + options.setBrowserVersion(browserVersion); + } + + if (!isBlank(platform)) { + options.setPlatformName(platform); + } + + options.setCapability("LT:Options", ltOptions); + return options; + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/src/test/java/com/seleniumboot/unit/CloudProviderTest.java b/src/test/java/com/seleniumboot/unit/CloudProviderTest.java index 4a5eee3..42408ce 100644 --- a/src/test/java/com/seleniumboot/unit/CloudProviderTest.java +++ b/src/test/java/com/seleniumboot/unit/CloudProviderTest.java @@ -2,7 +2,10 @@ import com.seleniumboot.config.SeleniumBootConfig; import com.seleniumboot.driver.BrowserStackProvider; +import com.seleniumboot.driver.LambdaTestProvider; import com.seleniumboot.driver.SauceLabsProvider; +import com.seleniumboot.internal.SeleniumBootContext; +import org.mockito.MockedStatic; import org.testng.annotations.Test; import static org.testng.Assert.*; @@ -144,4 +147,98 @@ public void saucelabs_configSetters() { assertEquals(cfg.getBrowser(), "safari"); assertEquals(cfg.getBrowserVersion(), "16.0"); } + + // ── LambdaTest ─────────────────────────────────────────────────────────── + + @Test + public void lambdatest_hubUrl_isCorrect() { + assertEquals(LambdaTestProvider.HUB_URL, + "https://hub.lambdatest.com/wd/hub"); + } + + @Test + public void lambdatest_sessionUrlPrefix_isCorrect() { + assertEquals(LambdaTestProvider.SESSION_URL_PREFIX, + "https://automation.lambdatest.com/logs/?sessionID="); + } + + @Test + public void lambdatest_sessionUrl_appendsSessionId() { + String url = LambdaTestProvider.SESSION_URL_PREFIX + "lt123"; + assertEquals(url, + "https://automation.lambdatest.com/logs/?sessionID=lt123"); + } + + @Test + public void lambdatest_configDefaults() { + SeleniumBootConfig.Execution.LambdaTest cfg = + new SeleniumBootConfig.Execution.LambdaTest(); + assertEquals(cfg.getPlatform(), "Windows 11"); + assertEquals(cfg.getBrowser(), "chrome"); + assertEquals(cfg.getBrowserVersion(), "latest"); + assertNotNull(cfg.getCapabilities()); + assertTrue(cfg.getCapabilities().isEmpty()); + } + + @Test + public void lambdatest_configSetters() { + SeleniumBootConfig.Execution.LambdaTest cfg = + new SeleniumBootConfig.Execution.LambdaTest(); + cfg.setUsername("ltUser"); + cfg.setAccessKey("ltKey"); + cfg.setPlatform("macOS Ventura"); + cfg.setBrowser("firefox"); + cfg.setBrowserVersion("120"); + + assertEquals(cfg.getUsername(), "ltUser"); + assertEquals(cfg.getAccessKey(), "ltKey"); + assertEquals(cfg.getPlatform(), "macOS Ventura"); + assertEquals(cfg.getBrowser(), "firefox"); + assertEquals(cfg.getBrowserVersion(), "120"); + } + + @Test + public void lambdatest_missingUsername_failsClearly() { + SeleniumBootConfig config = new SeleniumBootConfig(); + config.setExecution(new SeleniumBootConfig.Execution()); + try (MockedStatic context = + org.mockito.Mockito.mockStatic(SeleniumBootContext.class)) { + context.when(SeleniumBootContext::getConfig).thenReturn(config); + + IllegalStateException error; + try { + new LambdaTestProvider().createDriver(); + fail("Expected missing username to be rejected"); + return; + } catch (IllegalStateException e) { + error = e; + } + + assertEquals(error.getMessage(), + "[LambdaTest] execution.lambdatest.username is required"); + } + } + + @Test + public void lambdatest_missingAccessKey_failsClearly() { + SeleniumBootConfig config = new SeleniumBootConfig(); + config.setExecution(new SeleniumBootConfig.Execution()); + config.getExecution().getLambdatest().setUsername("ltUser"); + try (MockedStatic context = + org.mockito.Mockito.mockStatic(SeleniumBootContext.class)) { + context.when(SeleniumBootContext::getConfig).thenReturn(config); + + IllegalStateException error; + try { + new LambdaTestProvider().createDriver(); + fail("Expected missing access key to be rejected"); + return; + } catch (IllegalStateException e) { + error = e; + } + + assertEquals(error.getMessage(), + "[LambdaTest] execution.lambdatest.accessKey is required"); + } + } } diff --git a/src/test/java/com/seleniumboot/unit/ConfigurationLoaderTest.java b/src/test/java/com/seleniumboot/unit/ConfigurationLoaderTest.java index 60e09d2..722572d 100644 --- a/src/test/java/com/seleniumboot/unit/ConfigurationLoaderTest.java +++ b/src/test/java/com/seleniumboot/unit/ConfigurationLoaderTest.java @@ -186,6 +186,17 @@ public void load_invalidModeStillRejected() throws IOException { ConfigurationLoader.load(); } + @Test + public void load_lambdaTestMode_isAccepted() throws IOException { + Path file = Files.createTempFile("sb-lambdatest", ".yml"); + Files.writeString(file, "execution:\n mode: lambdatest\n"); + System.setProperty("selenium.boot.config", file.toString()); + + SeleniumBootConfig config = ConfigurationLoader.load(); + + assertEquals(config.getExecution().getMode(), "lambdatest"); + } + @Test(expectedExceptions = IllegalStateException.class) public void load_negativeTimeoutStillRejected() throws IOException { Path file = Files.createTempFile("sb-negtimeout", ".yml");