From 61226b42a862412042437061725804736e29311d Mon Sep 17 00:00:00 2001 From: Mohammad Saddam Hossain Date: Sun, 20 Sep 2026 10:36:28 +0530 Subject: [PATCH] =?UTF-8?q?feat(config):=20zero-config=20first=20run=20?= =?UTF-8?q?=E2=80=94=20selenium-boot.yml=20is=20now=20optional?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - No default selenium-boot.yml anywhere -> run on built-in defaults and print one notice line. A requested profile / explicit path that is missing still throws (a typo must not silently run on defaults). - Fields a file omits (and empty files) fall back to: browser.name=chrome (unless browser.matrix set), execution.mode=local, timeouts 10s/30s. Present-but-invalid values (unknown mode, negative timeout) still fail. - SeleniumBootDefaults overrides take precedence over built-ins and now apply before validation. - Adds ConfigurationLoader.load(File, ClassLoader) for tests. Traces back to #41/#43/#47 (cold-start friction on the config file). Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 2 +- docs-site/docs/configuration.md | 20 ++++ docs-site/docs/getting-started.md | 4 + .../config/ConfigurationLoader.java | 72 ++++++++++++++- .../unit/ConfigurationLoaderTest.java | 91 +++++++++++++++++++ 5 files changed, 183 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2e7fd3e..214a8d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,7 +77,7 @@ All source lives under `src/main/java/com/seleniumboot/`. Key packages: - **Registry + SPI** — `DriverProviderRegistry`, `PluginRegistry`, `ReportAdapterRegistry` use `ServiceLoader` for extensibility. - **ThreadLocal driver isolation** — `DriverManager` stores `WebDriver` per thread; safe for parallel TestNG execution. - **Template Method** — `BaseTest` / `BasePage` define framework-managed lifecycle; users override hook methods. -- **Convention over configuration** — `SeleniumBootDefaults` supplies fallbacks for optional fields, but `selenium-boot.yml` itself is **required**: `ConfigurationLoader` throws if the file is absent, and validates that `execution.mode`, `browser.name` (or `browser.matrix`), `timeouts.explicit` and `timeouts.pageLoad` are all present and positive. +- **Convention over configuration** — `selenium-boot.yml` is optional. `ConfigurationLoader` falls back to built-in defaults (`browser.name: chrome`, `execution.mode: local`, `timeouts.explicit: 10`, `timeouts.pageLoad: 30`) for an absent file (only when no profile / explicit path was requested — those still throw if missing) and for any omitted field; `SeleniumBootDefaults` programmatic overrides take precedence over the built-ins. Present-but-invalid values (unknown `execution.mode`, negative timeout) still fail validation. ### Public API Contract diff --git a/docs-site/docs/configuration.md b/docs-site/docs/configuration.md index 4567d06..2b710d1 100644 --- a/docs-site/docs/configuration.md +++ b/docs-site/docs/configuration.md @@ -18,6 +18,26 @@ The framework looks for the config file in this priority order: 1. **System property** — `-Dselenium.boot.config=/path/to/custom.yml` 2. **Working directory** — `./selenium-boot.yml` (next to `pom.xml`) 3. **Classpath** — `src/test/resources/selenium-boot.yml` +4. **Built-in defaults** — used only when none of the above exist *and* no profile or explicit + path was requested. The framework prints one line saying so. + +A requested profile (`-Dselenium.boot.profile=staging`) or explicit path that cannot be found is +always an error, so a typo never silently runs against defaults. + +## Defaults for omitted fields + +A file only has to say what it wants to change. Anything it leaves out (or an empty file) falls +back to: + +| Field | Default | +|---|---| +| `browser.name` | `chrome` (not applied when `browser.matrix` is set) | +| `execution.mode` | `local` | +| `timeouts.explicit` | `10` | +| `timeouts.pageLoad` | `30` | + +Values that are present but invalid — an unknown `execution.mode`, a negative timeout — still fail +at startup. --- diff --git a/docs-site/docs/getting-started.md b/docs-site/docs/getting-started.md index 478203c..6d58bf5 100644 --- a/docs-site/docs/getting-started.md +++ b/docs-site/docs/getting-started.md @@ -101,6 +101,10 @@ timeouts: pageLoad: 30 ``` +Every field here is optional — anything you leave out falls back to a default (Chrome, local +execution, 10 s / 30 s timeouts), and with no file at all the framework runs on those defaults. +See the [configuration reference](configuration.md) for the full list. + --- ## Step 3 — Write your first test diff --git a/src/main/java/com/seleniumboot/config/ConfigurationLoader.java b/src/main/java/com/seleniumboot/config/ConfigurationLoader.java index 04dfd8c..f0a0013 100644 --- a/src/main/java/com/seleniumboot/config/ConfigurationLoader.java +++ b/src/main/java/com/seleniumboot/config/ConfigurationLoader.java @@ -12,6 +12,11 @@ public final class ConfigurationLoader { + private static final String DEFAULT_BROWSER = "chrome"; + private static final String DEFAULT_MODE = "local"; + private static final int DEFAULT_EXPLICIT_TIMEOUT = 10; + private static final int DEFAULT_PAGE_LOAD_TIMEOUT = 30; + private ConfigurationLoader() { // utility class } @@ -22,9 +27,20 @@ private ConfigurationLoader() { *
  • System property {@code -Dselenium.boot.config=/path/to/file.yml} (explicit override)
  • *
  • {@code ./selenium-boot[-profile].yml} in the current working directory
  • *
  • {@code selenium-boot[-profile].yml} on the classpath (original behaviour)
  • + *
  • Built-in defaults — only when no profile and no explicit path was requested + * and the default {@code selenium-boot.yml} exists nowhere
  • * + * + *

    Fields a file leaves out fall back to the built-in defaults ({@link #applyBuiltInDefaults}); + * values that are present but invalid still fail validation. A requested profile or explicit + * path that cannot be found is always an error — silently falling back would hide a typo. */ public static SeleniumBootConfig load() { + return load(new File("."), ConfigurationLoader.class.getClassLoader()); + } + + /** Same as {@link #load()} with the working directory and classpath supplied — for tests. */ + public static SeleniumBootConfig load(File workingDir, ClassLoader classLoader) { String profile = System.getProperty("selenium.boot.profile"); String configFile = (profile == null || profile.isBlank()) @@ -38,16 +54,23 @@ public static SeleniumBootConfig load() { } // Priority 2: working directory - File workingDirFile = new File(configFile); + File workingDirFile = new File(workingDir, configFile); if (workingDirFile.exists()) { return loadFromFile(workingDirFile); } // Priority 3: classpath (original behaviour) - InputStream inputStream = ConfigurationLoader.class - .getClassLoader() - .getResourceAsStream(configFile); + InputStream inputStream = classLoader.getResourceAsStream(configFile); if (inputStream == null) { + if (profile == null || profile.isBlank()) { + System.out.println("[Selenium Boot] No " + configFile + " found — running with built-in defaults (" + + DEFAULT_BROWSER + ", " + DEFAULT_MODE + ", timeouts.explicit=" + DEFAULT_EXPLICIT_TIMEOUT + + "s, timeouts.pageLoad=" + DEFAULT_PAGE_LOAD_TIMEOUT + + "s). Add a " + configFile + " to customise."); + SeleniumBootConfig config = new SeleniumBootConfig(); + completeAndValidate(config); + return config; + } throw new IllegalStateException( "Configuration file '" + configFile + "' not found. " + "Checked: -Dselenium.boot.config, working directory, and classpath."); @@ -76,12 +99,51 @@ private static SeleniumBootConfig parseAndValidate(InputStream inputStream) { Yaml yaml = new Yaml(constructor); SeleniumBootConfig config = yaml.load(inputStream); + if (config == null) { + config = new SeleniumBootConfig(); // empty file — every field falls back to defaults + } - validate(config); + completeAndValidate(config); return config; } + private static void completeAndValidate(SeleniumBootConfig config) { + // Sections a file omits entirely are created so their fields can be defaulted. + if (config.getBrowser() == null) config.setBrowser(new SeleniumBootConfig.Browser()); + if (config.getExecution() == null) config.setExecution(new SeleniumBootConfig.Execution()); + if (config.getTimeouts() == null) config.setTimeouts(new SeleniumBootConfig.Timeouts()); + + // Programmatic defaults (SeleniumBootDefaults) take precedence over the built-in ones. + SeleniumBootDefaults.applyMissing(config); + applyBuiltInDefaults(config); + + validate(config); + } + + /** + * Fills fields the file left out. Only absent values are defaulted: + * {@code browser.name} (unless {@code browser.matrix} is set), {@code execution.mode}, + * and zero-valued timeouts. A negative timeout or an unrecognised mode is still rejected by + * {@link #validate}. + */ + private static void applyBuiltInDefaults(SeleniumBootConfig config) { + SeleniumBootConfig.Browser browser = config.getBrowser(); + boolean matrixConfigured = browser.getMatrix() != null && !browser.getMatrix().isEmpty(); + if (browser.getName() == null && !matrixConfigured) { + browser.setName(DEFAULT_BROWSER); + } + if (config.getExecution().getMode() == null) { + config.getExecution().setMode(DEFAULT_MODE); + } + if (config.getTimeouts().getExplicit() == 0) { + config.getTimeouts().setExplicit(DEFAULT_EXPLICIT_TIMEOUT); + } + if (config.getTimeouts().getPageLoad() == 0) { + config.getTimeouts().setPageLoad(DEFAULT_PAGE_LOAD_TIMEOUT); + } + } + private static void validate(SeleniumBootConfig config) { Objects.requireNonNull(config, "Configuration must not be null"); diff --git a/src/test/java/com/seleniumboot/unit/ConfigurationLoaderTest.java b/src/test/java/com/seleniumboot/unit/ConfigurationLoaderTest.java index e3c8b3e..3ab9e17 100644 --- a/src/test/java/com/seleniumboot/unit/ConfigurationLoaderTest.java +++ b/src/test/java/com/seleniumboot/unit/ConfigurationLoaderTest.java @@ -2,10 +2,18 @@ import com.seleniumboot.config.ConfigurationLoader; import com.seleniumboot.config.SeleniumBootConfig; +import com.seleniumboot.config.SeleniumBootDefaults; import org.testng.annotations.AfterMethod; import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; + import static org.testng.Assert.*; /** @@ -91,4 +99,87 @@ public void load_configHasPositiveThreadCount() { SeleniumBootConfig config = ConfigurationLoader.load(); assertTrue(config.getExecution().getMaxActiveSessions() > 0); } + + // ---------------------------------------------------------- + // Zero-config first run: defaults for what a file leaves out + // ---------------------------------------------------------- + + /** A classpath with no selenium-boot*.yml on it (null parent = no test resources). */ + private static ClassLoader emptyClasspath() { + return new URLClassLoader(new URL[0], null); + } + + private static File emptyWorkingDir() throws IOException { + return Files.createTempDirectory("sb-config").toFile(); + } + + @Test + public void load_noConfigFileAnywhere_usesBuiltInDefaults() throws IOException { + SeleniumBootConfig config = ConfigurationLoader.load(emptyWorkingDir(), emptyClasspath()); + + assertEquals(config.getBrowser().getName(), "chrome"); + assertEquals(config.getExecution().getMode(), "local"); + assertEquals(config.getTimeouts().getExplicit(), 10); + assertEquals(config.getTimeouts().getPageLoad(), 30); + } + + @Test(expectedExceptions = IllegalStateException.class) + public void load_noFileButProfileRequested_stillThrows() throws IOException { + System.setProperty("selenium.boot.profile", "staging"); + ConfigurationLoader.load(emptyWorkingDir(), emptyClasspath()); + } + + @Test + public void load_emptyFile_usesBuiltInDefaults() throws IOException { + Path file = Files.createTempFile("sb-empty", ".yml"); + System.setProperty("selenium.boot.config", file.toString()); + + SeleniumBootConfig config = ConfigurationLoader.load(); + + assertEquals(config.getBrowser().getName(), "chrome"); + assertEquals(config.getTimeouts().getExplicit(), 10); + } + + @Test + public void load_partialFile_keepsGivenValuesAndDefaultsTheRest() throws IOException { + Path file = Files.createTempFile("sb-partial", ".yml"); + Files.writeString(file, "browser:\n name: firefox\n headless: true\ntimeouts:\n explicit: 5\n"); + System.setProperty("selenium.boot.config", file.toString()); + + SeleniumBootConfig config = ConfigurationLoader.load(); + + assertEquals(config.getBrowser().getName(), "firefox"); + assertTrue(config.getBrowser().isHeadless()); + assertEquals(config.getTimeouts().getExplicit(), 5); + assertEquals(config.getTimeouts().getPageLoad(), 30, "omitted pageLoad falls back to the default"); + assertEquals(config.getExecution().getMode(), "local", "omitted execution section falls back"); + } + + @Test + public void load_matrixWithoutName_doesNotInjectDefaultBrowserName() throws IOException { + Path file = Files.createTempFile("sb-matrix", ".yml"); + Files.writeString(file, "browser:\n matrix: [chrome, firefox]\n"); + System.setProperty("selenium.boot.config", file.toString()); + + SeleniumBootConfig config = ConfigurationLoader.load(); + + assertNull(config.getBrowser().getName()); + assertEquals(config.getBrowser().getMatrix().size(), 2); + } + + @Test(expectedExceptions = IllegalStateException.class) + public void load_invalidModeStillRejected() throws IOException { + Path file = Files.createTempFile("sb-badmode", ".yml"); + Files.writeString(file, "execution:\n mode: banana\n"); + System.setProperty("selenium.boot.config", file.toString()); + ConfigurationLoader.load(); + } + + @Test(expectedExceptions = IllegalStateException.class) + public void load_negativeTimeoutStillRejected() throws IOException { + Path file = Files.createTempFile("sb-negtimeout", ".yml"); + Files.writeString(file, "timeouts:\n explicit: -3\n"); + System.setProperty("selenium.boot.config", file.toString()); + ConfigurationLoader.load(); + } }