diff --git a/CHANGELOG.md b/CHANGELOG.md index a522c67..8145ae8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/). --- +### Unreleased + +- **Gradle projects now get their reports under `build/`** (#81) — the base directory is decided from `build.gradle` / `build.gradle.kts` (a `pom.xml` wins if both exist) instead of "does `target/` exist", which other components had already created. Flakiness report, healed locators, traces, recordings, Allure results, screenshots and JUnit XML all follow it; `-Dseleniumboot.reports.dir` still overrides. +- **Docs: the Gradle `systemProperties System.properties` snippet is replaced** (#82) — copying every JVM property broke test runs when the test JVM differs from Gradle's. It now forwards only `browser` and `env`. + +--- + ### v3.5.0 — 2026-09-24 - **Zero-config first run: `selenium-boot.yml` is now optional** — with no default `selenium-boot.yml` in the working directory or on the classpath, the framework runs on built-in defaults (`browser.name: chrome`, `execution.mode: local`, `timeouts.explicit: 10`, `timeouts.pageLoad: 30`) and prints one `[Selenium Boot] No selenium-boot.yml found — running with built-in defaults ...` notice line. Fields a file omits (and an empty file) fall back to the same defaults; `browser.name` is only defaulted when `browser.matrix` is not set, and `SeleniumBootDefaults` overrides still take precedence over the built-ins. Nothing that was an error before stops being one: a requested profile (`-Dselenium.boot.profile`) or explicit path (`-Dselenium.boot.config`) whose file is missing still throws, so a typo never silently runs on defaults, and present-but-invalid values (an unknown `execution.mode`, a negative timeout) still fail validation. Adds `ConfigurationLoader.load(File, ClassLoader)` (working directory and classpath supplied, for tests). (#66) diff --git a/docs-site/docs/getting-started.md b/docs-site/docs/getting-started.md index 30d8a20..dd1687b 100644 --- a/docs-site/docs/getting-started.md +++ b/docs-site/docs/getting-started.md @@ -55,7 +55,7 @@ dependencies { test { useTestNG() - systemProperties System.properties + ['browser', 'env'].each { k -> System.getProperty(k)?.with { systemProperty k, it } } } ``` @@ -69,7 +69,7 @@ dependencies { tasks.test { useTestNG() - systemProperties(System.getProperties().mapKeys { it.key.toString() }) + listOf("browser", "env").forEach { k -> System.getProperty(k)?.let { systemProperty(k, it) } } } ``` @@ -198,7 +198,7 @@ mvn test 3. Your test runs 4. Screenshot captured on any failure 5. Browser closes -6. HTML report generated at `target/selenium-boot-report.html` (Maven) or `build/selenium-boot-report/` (Gradle) +6. HTML report generated at `target/selenium-boot-report.html` (Maven) or `build/selenium-boot-report.html` (Gradle) 7. Metrics JSON at `target/selenium-boot-metrics.json` --- diff --git a/docs-site/docs/gradle.md b/docs-site/docs/gradle.md index 0bdd776..ae6f510 100644 --- a/docs-site/docs/gradle.md +++ b/docs-site/docs/gradle.md @@ -69,8 +69,9 @@ test { // suites 'src/test/resources/testng.xml' } - // Forward system properties so -Denv=staging works from the CLI - systemProperties System.properties + // Forward -Denv=staging / -Dbrowser=firefox from the CLI. Forward only the keys you use: + // copying every JVM property (java.home, ...) breaks the run if the test JVM differs from Gradle's + ['browser', 'env'].each { k -> System.getProperty(k)?.with { systemProperty k, it } } // Display test output in the console testLogging { @@ -90,8 +91,9 @@ tasks.test { // suites("src/test/resources/testng.xml") } - // Forward system properties so -Denv=staging works from the CLI - systemProperties(System.getProperties().mapKeys { it.key.toString() }) + // Forward -Denv=staging / -Dbrowser=firefox from the CLI. Forward only the keys you use: + // copying every JVM property (java.home, ...) breaks the run if the test JVM differs from Gradle's + listOf("browser", "env").forEach { k -> System.getProperty(k)?.let { systemProperty(k, it) } } testLogging { events("passed", "skipped", "failed") @@ -119,7 +121,7 @@ dependencies { test { useJUnitPlatform() - systemProperties System.properties + ['browser', 'env'].each { k -> System.getProperty(k)?.with { systemProperty k, it } } } ``` @@ -135,7 +137,7 @@ dependencies { tasks.test { useJUnitPlatform() - systemProperties(System.getProperties().mapKeys { it.key.toString() }) + listOf("browser", "env").forEach { k -> System.getProperty(k)?.let { systemProperty(k, it) } } } ``` @@ -191,13 +193,13 @@ retry: | Report type | Gradle path | |---|---| -| HTML report (Selenium Boot) | `build/selenium-boot-report/index.html` | +| HTML report (Selenium Boot) | `build/selenium-boot-report.html` | | JUnit XML (Selenium Boot) | `build/test-results/test/TEST-SeleniumBoot.xml` | | Gradle's own HTML report | `build/reports/tests/test/index.html` | | Allure results (if enabled) | `build/allure-results/` | :::info JUnit XML auto-detection -Selenium Boot automatically detects Gradle by checking whether a `build/` directory exists and `target/` does not, then writes XML to `build/test-results/test/`. Override with `-Dseleniumboot.reports.dir=path/to/dir` if needed. +Selenium Boot detects a Gradle project by its `build.gradle` / `build.gradle.kts` (a `pom.xml` wins if both are present) and writes its reports, metrics and artifacts under `build/`, with JUnit XML in `build/test-results/test/`. Override with `-Dseleniumboot.reports.dir=path/to/dir` if needed. ::: --- @@ -213,7 +215,7 @@ For parallel runs with Gradle, configure the `test` task alongside `selenium-boo test { useTestNG() maxParallelForks = 4 // Gradle worker processes - systemProperties System.properties + ['browser', 'env'].each { k -> System.getProperty(k)?.with { systemProperty k, it } } } ``` @@ -224,7 +226,7 @@ test { tasks.test { useTestNG() maxParallelForks = 4 - systemProperties(System.getProperties().mapKeys { it.key.toString() }) + listOf("browser", "env").forEach { k -> System.getProperty(k)?.let { systemProperty(k, it) } } } ``` @@ -281,7 +283,7 @@ dependencies { test { useTestNG() - systemProperties System.properties + ['browser', 'env'].each { k -> System.getProperty(k)?.with { systemProperty k, it } } testLogging { events 'passed', 'skipped', 'failed' } } ``` diff --git a/src/main/java/com/seleniumboot/flakiness/FlakinessAnalyzer.java b/src/main/java/com/seleniumboot/flakiness/FlakinessAnalyzer.java index c54d1b6..eb3356b 100644 --- a/src/main/java/com/seleniumboot/flakiness/FlakinessAnalyzer.java +++ b/src/main/java/com/seleniumboot/flakiness/FlakinessAnalyzer.java @@ -144,7 +144,7 @@ static List loadHistoryFiles(int maxRuns) { private static void export(List scores) { if (scores.isEmpty()) return; try { - File out = new File("target/flakiness-report.json"); + File out = com.seleniumboot.reporting.ReportPaths.resolve("flakiness-report.json"); List> entries = scores.stream().map(s -> { Map m = new LinkedHashMap<>(); m.put("testId", s.getTestId()); diff --git a/src/main/java/com/seleniumboot/healing/HealLog.java b/src/main/java/com/seleniumboot/healing/HealLog.java index 2274d8c..b37c3ae 100644 --- a/src/main/java/com/seleniumboot/healing/HealLog.java +++ b/src/main/java/com/seleniumboot/healing/HealLog.java @@ -45,7 +45,7 @@ public static void clear() { public static void export() { if (EVENTS.isEmpty()) return; try { - File dir = new File("target"); + File dir = com.seleniumboot.reporting.ReportPaths.resolve(""); dir.mkdirs(); File out = new File(dir, "healed-locators.json"); diff --git a/src/main/java/com/seleniumboot/lifecycle/FrameworkBootstrap.java b/src/main/java/com/seleniumboot/lifecycle/FrameworkBootstrap.java index ae566c2..4bdc695 100644 --- a/src/main/java/com/seleniumboot/lifecycle/FrameworkBootstrap.java +++ b/src/main/java/com/seleniumboot/lifecycle/FrameworkBootstrap.java @@ -54,7 +54,7 @@ public static void initialize() { SeleniumBootConfig.Reporting reporting = config.getReporting(); if (reporting != null && reporting.isAllureEnabled()) { ReportAdapterRegistry.register(new AllureReportAdapter()); - System.out.println("[Selenium Boot] Allure adapter enabled → target/allure-results/"); + System.out.println("[Selenium Boot] Allure adapter enabled → " + com.seleniumboot.reporting.ReportPaths.resolve("allure-results") + "/"); } Notifications notifs = config.getNotifications(); diff --git a/src/main/java/com/seleniumboot/recording/RecordingManager.java b/src/main/java/com/seleniumboot/recording/RecordingManager.java index 2e9d879..d619a15 100644 --- a/src/main/java/com/seleniumboot/recording/RecordingManager.java +++ b/src/main/java/com/seleniumboot/recording/RecordingManager.java @@ -79,7 +79,7 @@ public static String saveOnFailure(String testId) { if (frames.isEmpty()) return null; String safeId = testId.replaceAll("[^a-zA-Z0-9._-]", "_"); - File dir = new File("target/recordings"); + File dir = com.seleniumboot.reporting.ReportPaths.resolve("recordings"); dir.mkdirs(); File output = new File(dir, safeId + ".gif"); diff --git a/src/main/java/com/seleniumboot/reporting/AllureReportAdapter.java b/src/main/java/com/seleniumboot/reporting/AllureReportAdapter.java index 26ef35a..09564e1 100644 --- a/src/main/java/com/seleniumboot/reporting/AllureReportAdapter.java +++ b/src/main/java/com/seleniumboot/reporting/AllureReportAdapter.java @@ -52,7 +52,7 @@ public void generate(File metricsJson) { JsonNode root = MAPPER.readTree(metricsJson); JsonNode tests = root.path("tests"); - File outputDir = new File("target/allure-results"); + File outputDir = ReportPaths.resolve("allure-results"); outputDir.mkdirs(); // Use the metrics file's modification time as the approximate suite-end timestamp. @@ -64,7 +64,7 @@ public void generate(File metricsJson) { writeTestResult(test, outputDir, suiteEnd); } - System.out.println("[Selenium Boot] Allure results → target/allure-results/ (" + System.out.println("[Selenium Boot] Allure results → " + ReportPaths.resolve("allure-results") + "/ (" + tests.size() + " tests)"); } catch (Exception e) { diff --git a/src/main/java/com/seleniumboot/reporting/JUnitXmlReporter.java b/src/main/java/com/seleniumboot/reporting/JUnitXmlReporter.java index d18d81e..b75a4e6 100644 --- a/src/main/java/com/seleniumboot/reporting/JUnitXmlReporter.java +++ b/src/main/java/com/seleniumboot/reporting/JUnitXmlReporter.java @@ -31,7 +31,7 @@ public final class JUnitXmlReporter { static String resolveOutputDir() { String override = System.getProperty("seleniumboot.reports.dir"); if (override != null && !override.isBlank()) return override.trim(); - if (new File("build").exists() && !new File("target").exists()) { + if (ReportPaths.usesGradleLayout()) { return "build/test-results/test"; } return "target/surefire-reports"; diff --git a/src/main/java/com/seleniumboot/reporting/ReportPaths.java b/src/main/java/com/seleniumboot/reporting/ReportPaths.java index 6541675..edf9e0e 100644 --- a/src/main/java/com/seleniumboot/reporting/ReportPaths.java +++ b/src/main/java/com/seleniumboot/reporting/ReportPaths.java @@ -34,10 +34,29 @@ public static String baseDir() { if (override != null && !override.isBlank()) { return override.trim(); } - if (new File("build").exists() && !new File("target").exists()) { - return "build"; - } - return "target"; + return usesGradleLayout() ? "build" : "target"; + } + + /** + * {@code true} for a Gradle project. Decided from the build files first — several components + * create {@code target/} themselves, so "does {@code target/} exist" cannot tell the two apart. + * A {@code pom.xml} wins when both build systems are present; with neither, falls back to + * "{@code build/} exists and {@code target/} does not". + */ + public static boolean usesGradleLayout() { + return usesGradleLayout(new File(".")); + } + + public static boolean usesGradleLayout(File projectDir) { + if (new File(projectDir, "pom.xml").exists()) return false; + if (new File(projectDir, "build.gradle").exists() + || new File(projectDir, "build.gradle.kts").exists()) return true; + return new File(projectDir, "build").exists() && !new File(projectDir, "target").exists(); + } + + /** A file or directory under {@link #baseDir()}, e.g. {@code resolve("recordings")}. */ + public static File resolve(String relativePath) { + return new File(baseDir(), relativePath); } /** {@code /selenium-boot-metrics.json} — the report's data source. */ diff --git a/src/main/java/com/seleniumboot/reporting/ScreenshotManager.java b/src/main/java/com/seleniumboot/reporting/ScreenshotManager.java index b899ac8..56b36bc 100644 --- a/src/main/java/com/seleniumboot/reporting/ScreenshotManager.java +++ b/src/main/java/com/seleniumboot/reporting/ScreenshotManager.java @@ -11,7 +11,6 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; import java.util.Base64; /** @@ -22,7 +21,6 @@ */ public final class ScreenshotManager { - private static final String REPORT_DIR = "target/reports/screenshots"; private ScreenshotManager() { } @@ -45,7 +43,7 @@ public static String capture(String testName) { try { File srcFile = ((TakesScreenshot) driver).getScreenshotAs(OutputType.FILE); - Path directory = Paths.get(REPORT_DIR); + Path directory = ReportPaths.resolve("reports/screenshots").toPath(); Files.createDirectories(directory); Path destination = directory.resolve( diff --git a/src/main/java/com/seleniumboot/tracing/TraceRecorder.java b/src/main/java/com/seleniumboot/tracing/TraceRecorder.java index 7fd4797..6a78c23 100644 --- a/src/main/java/com/seleniumboot/tracing/TraceRecorder.java +++ b/src/main/java/com/seleniumboot/tracing/TraceRecorder.java @@ -219,7 +219,7 @@ public static String buildHtml(TestTiming timing, String testName, String finalS // ------------------------------------------------------------------ private static File traceFile(String className, String testName) { - return new File("target/traces/" + className + "/" + sanitize(testName) + "-trace.html"); + return com.seleniumboot.reporting.ReportPaths.resolve("traces/" + className + "/" + sanitize(testName) + "-trace.html"); } static String captureFinalScreenshot(String testName) { diff --git a/src/test/java/com/seleniumboot/unit/ReportPathsTest.java b/src/test/java/com/seleniumboot/unit/ReportPathsTest.java index e18fa3b..39d0b0f 100644 --- a/src/test/java/com/seleniumboot/unit/ReportPathsTest.java +++ b/src/test/java/com/seleniumboot/unit/ReportPathsTest.java @@ -4,6 +4,9 @@ import org.testng.annotations.AfterMethod; import org.testng.annotations.Test; +import java.io.File; +import java.nio.file.Files; + import static org.testng.Assert.*; /** @@ -70,4 +73,49 @@ public void defaults_matchHistoricalTargetPaths() { assertEquals(ReportPaths.htmlReport().getPath(), "target/selenium-boot-report.html".replace('/', java.io.File.separatorChar)); } + + // --- build-tool detection (#81): must not depend on whether target/ already exists --- + + private static File project(String... files) throws Exception { + File dir = Files.createTempDirectory("sb-layout").toFile(); + for (String f : files) { + File file = new File(dir, f); + if (f.endsWith("/")) file.mkdirs(); else file.createNewFile(); + } + return dir; + } + + @Test + public void gradleProject_isGradleLayout_evenWhenTargetExists() throws Exception { + // the #81 case: a component created target/ before the path was resolved + assertTrue(ReportPaths.usesGradleLayout(project("build.gradle", "build/", "target/"))); + } + + @Test + public void gradleKotlinProject_isGradleLayout() throws Exception { + assertTrue(ReportPaths.usesGradleLayout(project("build.gradle.kts"))); + } + + @Test + public void mavenProject_isNotGradleLayout() throws Exception { + assertFalse(ReportPaths.usesGradleLayout(project("pom.xml", "target/"))); + } + + @Test + public void bothBuildFiles_mavenWins() throws Exception { + assertFalse(ReportPaths.usesGradleLayout(project("pom.xml", "build.gradle"))); + } + + @Test + public void noBuildFile_fallsBackToBuildDirHeuristic() throws Exception { + assertTrue(ReportPaths.usesGradleLayout(project("build/"))); + assertFalse(ReportPaths.usesGradleLayout(project("build/", "target/"))); + assertFalse(ReportPaths.usesGradleLayout(project())); + } + + @Test + public void resolve_isUnderBaseDir() { + System.setProperty("seleniumboot.reports.dir", "out"); + assertEquals(ReportPaths.resolve("recordings").getPath(), "out" + File.separator + "recordings"); + } }