diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfdb8b7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..348d2e1 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,56 @@ +name: End-to-End Tests + +on: + schedule: + # 07:00 UTC = 08:00 CET (Europe/Berlin winter time). GitHub cron is UTC only, + # so this fires at 09:00 local during CEST (summer time). + - cron: '0 7 * * *' + workflow_dispatch: + inputs: + step: + description: 'Tutorial step to test' + type: choice + default: all + options: + - all + - '1' + - '2' + - '3' + - '4' + - '5' + - '6' + +jobs: + e2e: + name: Playwright suite (Docker) + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + cache: maven + + - name: Run end-to-end test suite + env: + E2E_STEP: ${{ inputs.step || 'all' }} + run: bash scripts/run-e2e.sh "$E2E_STEP" + + - name: Upload failure artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-artifacts + path: | + */target/failsafe-reports + */target/visual-diffs + */target/playwright-traces + */target/e2e-artifacts + if-no-files-found: ignore + retention-days: 14 diff --git a/.gitignore b/.gitignore index 874890f..f558b30 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,9 @@ hs_err_pid* replay_pid* **/target +node_modules/ +playwright-report/ +test-results/ /.idea *.iml /.project diff --git a/1-creating-a-basic-app/README.md b/1-creating-a-basic-app/README.md index 6b97b67..19d53c1 100644 --- a/1-creating-a-basic-app/README.md +++ b/1-creating-a-basic-app/README.md @@ -14,6 +14,7 @@ To run the app, ensure the following tools are installed: - Java 21 or higher - BBj 26.02 when running with local BBjServices - Maven +- Docker Desktop (or another Docker engine) for end-to-end tests - A Java IDE (e.g., IntelliJ IDEA, Eclipse, VSCode) - Web browser - Git (recommended) @@ -49,6 +50,24 @@ webforj-tutorial ``` 3. Open your browser and go to [http://localhost:8080](http://localhost:8080). +## End-to-End and Screenshot Tests + +With Docker running, execute this command from this step's directory: + +```sh +mvn verify +``` + +Maven launches the step's Java Playwright tests in the pinned Docker image. Only this step is built and tested. Tests are in `src/test/java/com/webforj/tutorial`, screenshot baselines in `src/test/resources/screenshots`, and reports, traces, and logs under `target`. + +To update this step's baselines after an intentional visual change: + +```sh +mvn verify -DupdateScreenshots=true +``` + +Both test execution and baseline generation happen in Docker. `mvn test` runs unit tests; the end-to-end tests run during `mvn verify`. + ## Project Highlights - **Spring Boot integration:** Autowire Spring beans directly into webforJ views and components. diff --git a/1-creating-a-basic-app/pom.xml b/1-creating-a-basic-app/pom.xml index aace5df..bc0d243 100644 --- a/1-creating-a-basic-app/pom.xml +++ b/1-creating-a-basic-app/pom.xml @@ -18,6 +18,13 @@ 21 21 UTF-8 + 1.50.0 + 5.11.4 + *IT + false + false + ${maven.test.skip} + ${skipTests} @@ -53,7 +60,12 @@ com.microsoft.playwright playwright - 1.49.0 + ${playwright.version} + test + + + org.junit.jupiter + junit-jupiter test @@ -75,21 +87,9 @@ org.springframework.boot spring-boot-maven-plugin - true - - - - maven-failsafe-plugin - - - - integration-test - verify - - - - - false + + + @@ -105,5 +105,99 @@ true + + + docker-e2e + + + !docker.e2e.container + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + + docker-e2e + verify + exec + + ${skipITs} + docker + + run + --rm + --init + --ipc=host + --workdir=/app + -e + CI=true + -e + E2E_IN_DOCKER=true + --mount=type=bind,source=${project.basedir},target=/app + -v + webforj-tutorial-e2e-maven-repo:/var/maven/.m2 + -e + MAVEN_CONFIG=/var/maven/.m2 + mcr.microsoft.com/playwright/java:v${playwright.version}-noble + mvn + -B + -ntp + -Dmaven.repo.local=/var/maven/.m2/repository + -Ddocker.e2e.container=true + -DupdateScreenshots=${updateScreenshots} + -Dit.test=${it.test} + clean + verify + + + + + + + + + + + container-e2e + + + docker.e2e.container + true + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.2.5 + + + + integration-test + verify + + + + + true + false + false + + ${project.basedir} + ${project.build.directory}/${project.build.finalName}.jar + ${project.build.directory} + ${project.basedir}/src/test/resources/screenshots + ${project.build.directory}/visual-diffs + + + + + + diff --git a/1-creating-a-basic-app/src/test/java/com/webforj/tutorial/BaseTest.java b/1-creating-a-basic-app/src/test/java/com/webforj/tutorial/BaseTest.java new file mode 100644 index 0000000..f34110d --- /dev/null +++ b/1-creating-a-basic-app/src/test/java/com/webforj/tutorial/BaseTest.java @@ -0,0 +1,155 @@ +package com.webforj.tutorial; + +import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.microsoft.playwright.Browser; +import com.microsoft.playwright.BrowserContext; +import com.microsoft.playwright.BrowserType; +import com.microsoft.playwright.ConsoleMessage; +import com.microsoft.playwright.Locator; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.Playwright; +import com.microsoft.playwright.Request; +import com.microsoft.playwright.Tracing; +import com.microsoft.playwright.assertions.PlaywrightAssertions; +import com.microsoft.playwright.options.AriaRole; +import com.microsoft.playwright.options.ColorScheme; +import com.microsoft.playwright.options.WaitUntilState; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestInfo; +import org.junit.jupiter.api.TestInstance; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +abstract class BaseTest { + private Playwright playwright; + private Browser browser; + private BrowserContext context; + private TutorialApp application; + private final List browserErrors = new ArrayList<>(); + + protected Page page; + + @BeforeAll + void startBrowser() { + TutorialApp.requireDocker(); + playwright = Playwright.create(); + browser = playwright.chromium().launch(new BrowserType.LaunchOptions().setHeadless(true)); + PlaywrightAssertions.setDefaultAssertionTimeout(10_000); + } + + @BeforeEach + void startApplicationAndCreateBrowserContext() throws Exception { + browserErrors.clear(); + application = TutorialApp.start(); + context = browser.newContext(new Browser.NewContextOptions() + .setViewportSize(1440, 900) + .setDeviceScaleFactor(1) + .setColorScheme(ColorScheme.LIGHT) + .setLocale("en-US") + .setTimezoneId("UTC") + .setIgnoreHTTPSErrors(true)); + context.tracing().start(new Tracing.StartOptions() + .setScreenshots(true) + .setSnapshots(true) + .setSources(true)); + + page = context.newPage(); + page.onPageError(error -> browserErrors.add("pageerror: " + error)); + page.onConsoleMessage(this::recordConsoleError); + page.onRequestFailed(this::recordRequestFailure); + } + + @AfterEach + void closeBrowserContext(TestInfo testInfo) throws IOException { + List errorsBeforeClose = List.copyOf(browserErrors); + try { + if (context != null) { + Path traceDirectory = Path.of(System.getProperty("e2e.output.dir"), "playwright-traces"); + Files.createDirectories(traceDirectory); + String testName = (getClass().getSimpleName() + "-" + testInfo.getDisplayName()) + .replaceAll("[^a-zA-Z0-9.-]", "-"); + context.tracing().stop(new Tracing.StopOptions() + .setPath(traceDirectory.resolve(testName + ".zip"))); + context.close(); + context = null; + } + } finally { + if (application != null) { + application.close(); + application = null; + } + } + assertTrue(errorsBeforeClose.isEmpty(), + () -> "Browser runtime/network errors:\n" + String.join("\n", errorsBeforeClose)); + } + + @AfterAll + void stopBrowser() { + if (browser != null) { + browser.close(); + } + if (playwright != null) { + playwright.close(); + } + } + + protected void openApplication() { + openApplication("/"); + } + + protected void openApplication(String path) { + String normalizedPath = path.startsWith("/") ? path : "/" + path; + page.navigate(application.baseUrl() + normalizedPath, + new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED)); + } + + protected Locator customerTable() { + return page.locator("dwc-table"); + } + + protected Locator expectCustomerTable() { + Locator table = customerTable(); + assertThat(table).isVisible(); + assertThat(table).containsText("First Name"); + assertThat(table).containsText("Last Name"); + assertThat(table).containsText("Company"); + assertThat(table).containsText("Country"); + assertThat(table).containsText("Alice"); + assertThat(table).containsText("TechCorp"); + return table; + } + + protected Locator expectCustomerRow(String identifyingCellText) { + Locator identifyingCell = page.getByRole(AriaRole.CELL, + new Page.GetByRoleOptions().setName(identifyingCellText).setExact(true)); + Locator row = page.getByRole(AriaRole.ROW) + .filter(new Locator.FilterOptions().setHas(identifyingCell)); + assertThat(row).hasCount(1); + return row; + } + + private void recordConsoleError(ConsoleMessage message) { + if ("error".equals(message.type())) { + browserErrors.add("console: " + message.text()); + } + } + + private void recordRequestFailure(Request request) { + String failure = request.failure(); + boolean cancelledWebforjPoll = "net::ERR_ABORTED".equals(failure) + && request.url().contains("/webforjServlet/webapprmi"); + if (!cancelledWebforjPoll) { + browserErrors.add( + "requestfailed: " + request.method() + " " + request.url() + " (" + failure + ")"); + } + } +} diff --git a/1-creating-a-basic-app/src/test/java/com/webforj/tutorial/Step1IT.java b/1-creating-a-basic-app/src/test/java/com/webforj/tutorial/Step1IT.java new file mode 100644 index 0000000..f041393 --- /dev/null +++ b/1-creating-a-basic-app/src/test/java/com/webforj/tutorial/Step1IT.java @@ -0,0 +1,25 @@ +package com.webforj.tutorial; + +import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; + +import com.microsoft.playwright.Page; +import com.microsoft.playwright.options.AriaRole; +import java.io.IOException; +import org.junit.jupiter.api.Test; + +class Step1IT extends BaseTest { + @Test + void rendersBasicApplicationAndOpensInformationDialog() throws IOException { + openApplication(); + + assertThat(page.getByText("Tutorial App!", new Page.GetByTextOptions().setExact(true))).isVisible(); + var infoButton = page.getByRole( + AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Info").setExact(true)); + assertThat(infoButton).isVisible(); + infoButton.click(); + + assertThat(page.getByRole(AriaRole.DIALOG)).isVisible(); + assertThat(page.getByText("This is a tutorial!", new Page.GetByTextOptions().setExact(true))).isVisible(); + VisualAssertions.assertScreenshot(page, "basic-application-dialog.png"); + } +} diff --git a/1-creating-a-basic-app/src/test/java/com/webforj/tutorial/TutorialApp.java b/1-creating-a-basic-app/src/test/java/com/webforj/tutorial/TutorialApp.java new file mode 100644 index 0000000..9d3ae51 --- /dev/null +++ b/1-creating-a-basic-app/src/test/java/com/webforj/tutorial/TutorialApp.java @@ -0,0 +1,159 @@ +package com.webforj.tutorial; + +import java.io.IOException; +import java.net.ServerSocket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.TimeUnit; + +final class TutorialApp implements AutoCloseable { + private static final Duration STARTUP_TIMEOUT = Duration.ofMinutes(3); + private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(2)) + .build(); + + private final Process process; + private final String baseUrl; + private final Path logFile; + + private TutorialApp(Process process, String baseUrl, Path logFile) { + this.process = process; + this.baseUrl = baseUrl; + this.logFile = logFile; + } + + static TutorialApp start() throws Exception { + requireDocker(); + + Path stepPath = Path.of(System.getProperty("e2e.app.dir")).toAbsolutePath().normalize(); + Path jar = Path.of(System.getProperty("e2e.app.jar")).toAbsolutePath().normalize(); + if (!Files.isRegularFile(jar)) { + throw new IllegalStateException("Application JAR does not exist: " + jar + + ". Run the complete Maven verify lifecycle to package the application first."); + } + + int port = findFreePort(); + String baseUrl = "http://127.0.0.1:" + port; + Path logFile = Path.of(System.getProperty("e2e.output.dir"), "e2e-artifacts", "server.log"); + Files.createDirectories(logFile.getParent()); + + String javaExecutable = Path.of(System.getProperty("java.home"), "bin", "java").toString(); + ProcessBuilder processBuilder = new ProcessBuilder( + javaExecutable, + "-jar", + jar.toString(), + "--server.port=" + port, + "--server.address=127.0.0.1", + "--spring.jpa.hibernate.ddl-auto=create-drop", + "--webforj.devtools.browser.open=false", + "--webforj.devtools.livereload.enabled=false", + "--webforj.devtools.livereload.static-resources-enabled=false", + "--webforj.devtools.craftforj.enabled=false", + "--webforj.debug=false"); + processBuilder.directory(stepPath.toFile()); + processBuilder.redirectErrorStream(true); + processBuilder.redirectOutput(logFile.toFile()); + + Process process = processBuilder.start(); + TutorialApp app = new TutorialApp(process, baseUrl, logFile); + try { + app.waitUntilReady(); + return app; + } catch (Exception exception) { + app.close(); + throw exception; + } + } + + String baseUrl() { + return baseUrl; + } + + static void requireDocker() { + if (!"true".equalsIgnoreCase(System.getenv("E2E_IN_DOCKER"))) { + throw new IllegalStateException( + "E2E tests may only run in Docker. Run `mvn verify` from this tutorial step's directory."); + } + } + + private static int findFreePort() throws IOException { + for (int preferredPort : List.of(8080, 8090)) { + try (ServerSocket ignored = new ServerSocket(preferredPort)) { + return preferredPort; + } catch (IOException ignored) { + // Try the next preferred port. + } + } + + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } + + private void waitUntilReady() throws Exception { + long deadline = System.nanoTime() + STARTUP_TIMEOUT.toNanos(); + HttpRequest request = HttpRequest.newBuilder(URI.create(baseUrl + "/")) + .timeout(Duration.ofSeconds(3)) + .GET() + .build(); + + while (System.nanoTime() < deadline) { + if (!process.isAlive()) { + throw new IllegalStateException( + "Application exited before becoming ready.\n" + tailLog()); + } + + try { + HttpResponse response = HTTP_CLIENT.send( + request, HttpResponse.BodyHandlers.discarding()); + if (response.statusCode() < 500) { + return; + } + } catch (IOException ignored) { + // The server is still starting. + } + + Thread.sleep(500); + } + + throw new IllegalStateException( + "Application did not become ready within " + STARTUP_TIMEOUT + ".\n" + tailLog()); + } + + private String tailLog() { + try { + List lines = Files.readAllLines(logFile, StandardCharsets.UTF_8); + int start = Math.max(0, lines.size() - 100); + return "Server log: " + logFile + "\n" + String.join("\n", lines.subList(start, lines.size())); + } catch (IOException exception) { + return "Could not read server log " + logFile + ": " + exception.getMessage(); + } + } + + @Override + public void close() { + if (!process.isAlive()) { + return; + } + + process.descendants().forEach(ProcessHandle::destroy); + process.destroy(); + try { + if (!process.waitFor(10, TimeUnit.SECONDS)) { + process.descendants().forEach(ProcessHandle::destroyForcibly); + process.destroyForcibly(); + process.waitFor(5, TimeUnit.SECONDS); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + process.destroyForcibly(); + } + } +} diff --git a/1-creating-a-basic-app/src/test/java/com/webforj/tutorial/VisualAssertions.java b/1-creating-a-basic-app/src/test/java/com/webforj/tutorial/VisualAssertions.java new file mode 100644 index 0000000..c42d6b9 --- /dev/null +++ b/1-creating-a-basic-app/src/test/java/com/webforj/tutorial/VisualAssertions.java @@ -0,0 +1,198 @@ +package com.webforj.tutorial; + +import static org.junit.jupiter.api.Assertions.fail; + +import com.microsoft.playwright.Page; +import com.microsoft.playwright.options.ScreenshotAnimations; +import com.microsoft.playwright.options.ScreenshotCaret; +import com.microsoft.playwright.options.ScreenshotScale; +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import javax.imageio.ImageIO; + +final class VisualAssertions { + private static final int DEFAULT_MAX_DIFFERENT_PIXELS = 500; + private static final int CHANNEL_THRESHOLD = 51; + private static final int DEFAULT_TIMEOUT_MS = 10_000; + private static final int POLL_INTERVAL_MS = 250; + + /** + * Every {@code dwc-icon} resolves its artwork over the network and injects an {@code } into + * its own shadow root, so an unresolved icon is invisible in a screenshot while the surrounding + * DOM already looks complete. Icons live inside nested shadow roots, hence the manual walk. + */ + private static final String ICONS_RENDERED = """ + () => { + const icons = []; + const walk = (root) => { + for (const element of root.querySelectorAll('*')) { + if (element.localName === 'dwc-icon') { + icons.push(element); + } + if (element.shadowRoot) { + walk(element.shadowRoot); + } + } + }; + walk(document); + return icons.every(icon => icon.shadowRoot && icon.shadowRoot.querySelector('svg')); + } + """; + + private VisualAssertions() { + } + + static void assertScreenshot(Page page, String name) throws IOException { + requirePngName(name); + awaitRenderedIcons(page); + + Path baselineDirectory = Path.of(requiredProperty("visual.baseline.dir")); + Path artifactDirectory = Path.of(requiredProperty("visual.artifact.dir")); + Path baseline = baselineDirectory.resolve(name); + byte[] screenshot = captureStable(page, name, artifactDirectory); + + if (Boolean.getBoolean("updateScreenshots")) { + requireDocker(); + Files.createDirectories(baselineDirectory); + Files.write(baseline, screenshot); + return; + } + + if (!Files.exists(baseline)) { + fail("Missing Docker screenshot baseline " + baseline + + ". Generate it with `mvn verify -DupdateScreenshots=true`."); + } + + BufferedImage expected = ImageIO.read(baseline.toFile()); + if (expected == null) { + fail("Could not decode screenshot baseline for " + name); + } + + BufferedImage actual = ImageIO.read(new ByteArrayInputStream(screenshot)); + if (actual == null) { + fail("Could not decode captured screenshot for " + name); + } + + if (expected.getWidth() != actual.getWidth() || expected.getHeight() != actual.getHeight()) { + writeFailureArtifacts(artifactDirectory, name, screenshot, null); + fail("Screenshot dimensions differ for " + name + ": expected " + + expected.getWidth() + "x" + expected.getHeight() + ", actual " + + actual.getWidth() + "x" + actual.getHeight()); + } + + BufferedImage diff = new BufferedImage( + actual.getWidth(), actual.getHeight(), BufferedImage.TYPE_INT_ARGB); + int differentPixels = comparePixels(expected, actual, diff); + int maximum = Integer.getInteger("visual.maxDiffPixels", DEFAULT_MAX_DIFFERENT_PIXELS); + if (differentPixels > maximum) { + writeFailureArtifacts(artifactDirectory, name, screenshot, diff); + fail("Screenshot mismatch for " + name + ": " + differentPixels + + " pixels differ (maximum " + maximum + "). See " + artifactDirectory); + } + } + + private static void awaitRenderedIcons(Page page) { + try { + page.waitForFunction(ICONS_RENDERED, null, + new Page.WaitForFunctionOptions().setTimeout(timeoutMs())); + } catch (RuntimeException e) { + fail("Icons did not finish rendering within " + timeoutMs() + + " ms; screenshots would capture missing icons. Icon artwork is fetched from" + + " cdn.jsdelivr.net, so check network access from the container.", e); + } + } + + private static byte[] capture(Page page) { + page.evaluate("() => document.fonts.ready"); + return page.screenshot(new Page.ScreenshotOptions() + .setAnimations(ScreenshotAnimations.DISABLED) + .setCaret(ScreenshotCaret.HIDE) + .setScale(ScreenshotScale.CSS)); + } + + private static byte[] captureStable( + Page page, String name, Path artifactDirectory) throws IOException { + long deadline = System.nanoTime() + timeoutMs() * 1_000_000L; + byte[] previous = capture(page); + while (System.nanoTime() < deadline) { + page.waitForTimeout(POLL_INTERVAL_MS); + byte[] current = capture(page); + if (Arrays.equals(previous, current)) { + return current; + } + previous = current; + } + + writeFailureArtifacts(artifactDirectory, name, previous, null); + return fail("Page did not produce two consecutive identical screenshots for " + name + + " within " + timeoutMs() + " ms. Refusing to compare or update an unstable image. See " + + artifactDirectory); + } + + private static int comparePixels(BufferedImage expected, BufferedImage actual, BufferedImage diff) { + int differentPixels = 0; + for (int y = 0; y < actual.getHeight(); y++) { + for (int x = 0; x < actual.getWidth(); x++) { + int expectedRgb = expected.getRGB(x, y); + int actualRgb = actual.getRGB(x, y); + if (isDifferent(expectedRgb, actualRgb)) { + differentPixels++; + diff.setRGB(x, y, Color.MAGENTA.getRGB()); + } else { + Color pixel = new Color(actualRgb, true); + int gray = (pixel.getRed() + pixel.getGreen() + pixel.getBlue()) / 3; + diff.setRGB(x, y, new Color(gray, gray, gray, 110).getRGB()); + } + } + } + return differentPixels; + } + + private static int timeoutMs() { + return Integer.getInteger("visual.timeoutMs", DEFAULT_TIMEOUT_MS); + } + + private static boolean isDifferent(int expectedRgb, int actualRgb) { + Color expected = new Color(expectedRgb, true); + Color actual = new Color(actualRgb, true); + return Math.abs(expected.getRed() - actual.getRed()) > CHANNEL_THRESHOLD + || Math.abs(expected.getGreen() - actual.getGreen()) > CHANNEL_THRESHOLD + || Math.abs(expected.getBlue() - actual.getBlue()) > CHANNEL_THRESHOLD + || Math.abs(expected.getAlpha() - actual.getAlpha()) > CHANNEL_THRESHOLD; + } + + private static void writeFailureArtifacts( + Path artifactDirectory, String name, byte[] screenshot, BufferedImage diff) throws IOException { + Files.createDirectories(artifactDirectory); + String stem = name.substring(0, name.length() - ".png".length()); + Files.write(artifactDirectory.resolve(stem + "-actual.png"), screenshot); + if (diff != null) { + ImageIO.write(diff, "png", artifactDirectory.resolve(stem + "-diff.png").toFile()); + } + } + + private static String requiredProperty(String name) { + String value = System.getProperty(name); + if (value == null || value.isBlank()) { + throw new IllegalStateException("Missing required system property: " + name); + } + return value; + } + + private static void requirePngName(String name) { + if (!name.matches("[a-z0-9-]+\\.png")) { + throw new IllegalArgumentException("Screenshot name must be a simple kebab-case PNG filename: " + name); + } + } + + private static void requireDocker() { + if (!"true".equalsIgnoreCase(System.getenv("E2E_IN_DOCKER"))) { + throw new IllegalStateException("Screenshot baselines may only be generated in Docker."); + } + } +} diff --git a/1-creating-a-basic-app/src/test/resources/screenshots/basic-application-dialog.png b/1-creating-a-basic-app/src/test/resources/screenshots/basic-application-dialog.png new file mode 100644 index 0000000..540f487 Binary files /dev/null and b/1-creating-a-basic-app/src/test/resources/screenshots/basic-application-dialog.png differ diff --git a/2-working-with-data/README.md b/2-working-with-data/README.md index 6b97b67..19d53c1 100644 --- a/2-working-with-data/README.md +++ b/2-working-with-data/README.md @@ -14,6 +14,7 @@ To run the app, ensure the following tools are installed: - Java 21 or higher - BBj 26.02 when running with local BBjServices - Maven +- Docker Desktop (or another Docker engine) for end-to-end tests - A Java IDE (e.g., IntelliJ IDEA, Eclipse, VSCode) - Web browser - Git (recommended) @@ -49,6 +50,24 @@ webforj-tutorial ``` 3. Open your browser and go to [http://localhost:8080](http://localhost:8080). +## End-to-End and Screenshot Tests + +With Docker running, execute this command from this step's directory: + +```sh +mvn verify +``` + +Maven launches the step's Java Playwright tests in the pinned Docker image. Only this step is built and tested. Tests are in `src/test/java/com/webforj/tutorial`, screenshot baselines in `src/test/resources/screenshots`, and reports, traces, and logs under `target`. + +To update this step's baselines after an intentional visual change: + +```sh +mvn verify -DupdateScreenshots=true +``` + +Both test execution and baseline generation happen in Docker. `mvn test` runs unit tests; the end-to-end tests run during `mvn verify`. + ## Project Highlights - **Spring Boot integration:** Autowire Spring beans directly into webforJ views and components. diff --git a/2-working-with-data/pom.xml b/2-working-with-data/pom.xml index 148d14d..857abf5 100644 --- a/2-working-with-data/pom.xml +++ b/2-working-with-data/pom.xml @@ -18,6 +18,13 @@ 21 21 UTF-8 + 1.50.0 + 5.11.4 + *IT + false + false + ${maven.test.skip} + ${skipTests} @@ -61,7 +68,12 @@ com.microsoft.playwright playwright - 1.49.0 + ${playwright.version} + test + + + org.junit.jupiter + junit-jupiter test @@ -83,21 +95,9 @@ org.springframework.boot spring-boot-maven-plugin - true - - - - maven-failsafe-plugin - - - - integration-test - verify - - - - - false + + + @@ -113,5 +113,99 @@ true + + + docker-e2e + + + !docker.e2e.container + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + + docker-e2e + verify + exec + + ${skipITs} + docker + + run + --rm + --init + --ipc=host + --workdir=/app + -e + CI=true + -e + E2E_IN_DOCKER=true + --mount=type=bind,source=${project.basedir},target=/app + -v + webforj-tutorial-e2e-maven-repo:/var/maven/.m2 + -e + MAVEN_CONFIG=/var/maven/.m2 + mcr.microsoft.com/playwright/java:v${playwright.version}-noble + mvn + -B + -ntp + -Dmaven.repo.local=/var/maven/.m2/repository + -Ddocker.e2e.container=true + -DupdateScreenshots=${updateScreenshots} + -Dit.test=${it.test} + clean + verify + + + + + + + + + + + container-e2e + + + docker.e2e.container + true + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.2.5 + + + + integration-test + verify + + + + + true + false + false + + ${project.basedir} + ${project.build.directory}/${project.build.finalName}.jar + ${project.build.directory} + ${project.basedir}/src/test/resources/screenshots + ${project.build.directory}/visual-diffs + + + + + + diff --git a/2-working-with-data/src/main/resources/application.properties b/2-working-with-data/src/main/resources/application.properties index 872c0a9..91aa247 100644 --- a/2-working-with-data/src/main/resources/application.properties +++ b/2-working-with-data/src/main/resources/application.properties @@ -1,7 +1,10 @@ -# Spring Boot configuration -spring.application.name=CustomerApplication +# webforJ configuration +webforj.entry=com.webforj.tutorial.Application +webforj.debug=true +webforj.devtools.craftforj.enabled=true +webforj.devtools.livereload.enabled=true -# Server configuration +# Hot reload configuration server.shutdown=immediate server.port=8080 @@ -15,14 +18,6 @@ webforj.entry = com.webforj.tutorial.Application # H2 Database configuration spring.datasource.url=jdbc:h2:mem:testdb -spring.datasource.driverClassName=org.h2.Driver -spring.datasource.username=sa -spring.datasource.password= # JPA configuration -spring.jpa.database-platform=org.hibernate.dialect.H2Dialect spring.jpa.hibernate.ddl-auto=update - -# H2 Console (optional - for development) -# spring.h2.console.enabled=true -# spring.h2.console.path=/h2-console diff --git a/2-working-with-data/src/test/java/com/webforj/tutorial/BaseTest.java b/2-working-with-data/src/test/java/com/webforj/tutorial/BaseTest.java new file mode 100644 index 0000000..f34110d --- /dev/null +++ b/2-working-with-data/src/test/java/com/webforj/tutorial/BaseTest.java @@ -0,0 +1,155 @@ +package com.webforj.tutorial; + +import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.microsoft.playwright.Browser; +import com.microsoft.playwright.BrowserContext; +import com.microsoft.playwright.BrowserType; +import com.microsoft.playwright.ConsoleMessage; +import com.microsoft.playwright.Locator; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.Playwright; +import com.microsoft.playwright.Request; +import com.microsoft.playwright.Tracing; +import com.microsoft.playwright.assertions.PlaywrightAssertions; +import com.microsoft.playwright.options.AriaRole; +import com.microsoft.playwright.options.ColorScheme; +import com.microsoft.playwright.options.WaitUntilState; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestInfo; +import org.junit.jupiter.api.TestInstance; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +abstract class BaseTest { + private Playwright playwright; + private Browser browser; + private BrowserContext context; + private TutorialApp application; + private final List browserErrors = new ArrayList<>(); + + protected Page page; + + @BeforeAll + void startBrowser() { + TutorialApp.requireDocker(); + playwright = Playwright.create(); + browser = playwright.chromium().launch(new BrowserType.LaunchOptions().setHeadless(true)); + PlaywrightAssertions.setDefaultAssertionTimeout(10_000); + } + + @BeforeEach + void startApplicationAndCreateBrowserContext() throws Exception { + browserErrors.clear(); + application = TutorialApp.start(); + context = browser.newContext(new Browser.NewContextOptions() + .setViewportSize(1440, 900) + .setDeviceScaleFactor(1) + .setColorScheme(ColorScheme.LIGHT) + .setLocale("en-US") + .setTimezoneId("UTC") + .setIgnoreHTTPSErrors(true)); + context.tracing().start(new Tracing.StartOptions() + .setScreenshots(true) + .setSnapshots(true) + .setSources(true)); + + page = context.newPage(); + page.onPageError(error -> browserErrors.add("pageerror: " + error)); + page.onConsoleMessage(this::recordConsoleError); + page.onRequestFailed(this::recordRequestFailure); + } + + @AfterEach + void closeBrowserContext(TestInfo testInfo) throws IOException { + List errorsBeforeClose = List.copyOf(browserErrors); + try { + if (context != null) { + Path traceDirectory = Path.of(System.getProperty("e2e.output.dir"), "playwright-traces"); + Files.createDirectories(traceDirectory); + String testName = (getClass().getSimpleName() + "-" + testInfo.getDisplayName()) + .replaceAll("[^a-zA-Z0-9.-]", "-"); + context.tracing().stop(new Tracing.StopOptions() + .setPath(traceDirectory.resolve(testName + ".zip"))); + context.close(); + context = null; + } + } finally { + if (application != null) { + application.close(); + application = null; + } + } + assertTrue(errorsBeforeClose.isEmpty(), + () -> "Browser runtime/network errors:\n" + String.join("\n", errorsBeforeClose)); + } + + @AfterAll + void stopBrowser() { + if (browser != null) { + browser.close(); + } + if (playwright != null) { + playwright.close(); + } + } + + protected void openApplication() { + openApplication("/"); + } + + protected void openApplication(String path) { + String normalizedPath = path.startsWith("/") ? path : "/" + path; + page.navigate(application.baseUrl() + normalizedPath, + new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED)); + } + + protected Locator customerTable() { + return page.locator("dwc-table"); + } + + protected Locator expectCustomerTable() { + Locator table = customerTable(); + assertThat(table).isVisible(); + assertThat(table).containsText("First Name"); + assertThat(table).containsText("Last Name"); + assertThat(table).containsText("Company"); + assertThat(table).containsText("Country"); + assertThat(table).containsText("Alice"); + assertThat(table).containsText("TechCorp"); + return table; + } + + protected Locator expectCustomerRow(String identifyingCellText) { + Locator identifyingCell = page.getByRole(AriaRole.CELL, + new Page.GetByRoleOptions().setName(identifyingCellText).setExact(true)); + Locator row = page.getByRole(AriaRole.ROW) + .filter(new Locator.FilterOptions().setHas(identifyingCell)); + assertThat(row).hasCount(1); + return row; + } + + private void recordConsoleError(ConsoleMessage message) { + if ("error".equals(message.type())) { + browserErrors.add("console: " + message.text()); + } + } + + private void recordRequestFailure(Request request) { + String failure = request.failure(); + boolean cancelledWebforjPoll = "net::ERR_ABORTED".equals(failure) + && request.url().contains("/webforjServlet/webapprmi"); + if (!cancelledWebforjPoll) { + browserErrors.add( + "requestfailed: " + request.method() + " " + request.url() + " (" + failure + ")"); + } + } +} diff --git a/2-working-with-data/src/test/java/com/webforj/tutorial/Step2IT.java b/2-working-with-data/src/test/java/com/webforj/tutorial/Step2IT.java new file mode 100644 index 0000000..7874f58 --- /dev/null +++ b/2-working-with-data/src/test/java/com/webforj/tutorial/Step2IT.java @@ -0,0 +1,44 @@ +package com.webforj.tutorial; + +import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; + +import com.microsoft.playwright.Locator; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.options.AriaRole; +import java.io.IOException; +import org.junit.jupiter.api.Test; + +class Step2IT extends BaseTest { + @Test + void rendersSeededDataDialogAndSorting() throws IOException { + openApplication(); + + assertThat(page.getByText("Tutorial App!", new Page.GetByTextOptions().setExact(true))).isVisible(); + expectCustomerTable(); + assertThat(expectCustomerRow("John")).containsText("Innovatech"); + + page.getByRole(AriaRole.BUTTON, + new Page.GetByRoleOptions().setName("Info").setExact(true)).click(); + assertThat(page.getByText("This is a tutorial!", new Page.GetByTextOptions().setExact(true))).isVisible(); + page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("OK")).click(); + assertThat(page.getByRole(AriaRole.DIALOG)).isHidden(); + VisualAssertions.assertScreenshot(page, "customer-table.png"); + + Locator firstNameHeader = page.locator( + "dwc-table [part~='cell-header'][data-column='firstName']"); + Locator firstNameCells = page.locator( + "dwc-table [part~='cell'][data-column='firstName']:not([part~='cell-header'])"); + + firstNameHeader.click(); + assertThat(firstNameCells).hasText(new String[] { + "Alice", "Emma", "Isabella", "James", "John", + "Liam", "Lucas", "Noah", "Olivia", "Sophia" + }); + + firstNameHeader.click(); + assertThat(firstNameCells).hasText(new String[] { + "Sophia", "Olivia", "Noah", "Lucas", "Liam", + "John", "James", "Isabella", "Emma", "Alice" + }); + } +} diff --git a/2-working-with-data/src/test/java/com/webforj/tutorial/TutorialApp.java b/2-working-with-data/src/test/java/com/webforj/tutorial/TutorialApp.java new file mode 100644 index 0000000..9d3ae51 --- /dev/null +++ b/2-working-with-data/src/test/java/com/webforj/tutorial/TutorialApp.java @@ -0,0 +1,159 @@ +package com.webforj.tutorial; + +import java.io.IOException; +import java.net.ServerSocket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.TimeUnit; + +final class TutorialApp implements AutoCloseable { + private static final Duration STARTUP_TIMEOUT = Duration.ofMinutes(3); + private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(2)) + .build(); + + private final Process process; + private final String baseUrl; + private final Path logFile; + + private TutorialApp(Process process, String baseUrl, Path logFile) { + this.process = process; + this.baseUrl = baseUrl; + this.logFile = logFile; + } + + static TutorialApp start() throws Exception { + requireDocker(); + + Path stepPath = Path.of(System.getProperty("e2e.app.dir")).toAbsolutePath().normalize(); + Path jar = Path.of(System.getProperty("e2e.app.jar")).toAbsolutePath().normalize(); + if (!Files.isRegularFile(jar)) { + throw new IllegalStateException("Application JAR does not exist: " + jar + + ". Run the complete Maven verify lifecycle to package the application first."); + } + + int port = findFreePort(); + String baseUrl = "http://127.0.0.1:" + port; + Path logFile = Path.of(System.getProperty("e2e.output.dir"), "e2e-artifacts", "server.log"); + Files.createDirectories(logFile.getParent()); + + String javaExecutable = Path.of(System.getProperty("java.home"), "bin", "java").toString(); + ProcessBuilder processBuilder = new ProcessBuilder( + javaExecutable, + "-jar", + jar.toString(), + "--server.port=" + port, + "--server.address=127.0.0.1", + "--spring.jpa.hibernate.ddl-auto=create-drop", + "--webforj.devtools.browser.open=false", + "--webforj.devtools.livereload.enabled=false", + "--webforj.devtools.livereload.static-resources-enabled=false", + "--webforj.devtools.craftforj.enabled=false", + "--webforj.debug=false"); + processBuilder.directory(stepPath.toFile()); + processBuilder.redirectErrorStream(true); + processBuilder.redirectOutput(logFile.toFile()); + + Process process = processBuilder.start(); + TutorialApp app = new TutorialApp(process, baseUrl, logFile); + try { + app.waitUntilReady(); + return app; + } catch (Exception exception) { + app.close(); + throw exception; + } + } + + String baseUrl() { + return baseUrl; + } + + static void requireDocker() { + if (!"true".equalsIgnoreCase(System.getenv("E2E_IN_DOCKER"))) { + throw new IllegalStateException( + "E2E tests may only run in Docker. Run `mvn verify` from this tutorial step's directory."); + } + } + + private static int findFreePort() throws IOException { + for (int preferredPort : List.of(8080, 8090)) { + try (ServerSocket ignored = new ServerSocket(preferredPort)) { + return preferredPort; + } catch (IOException ignored) { + // Try the next preferred port. + } + } + + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } + + private void waitUntilReady() throws Exception { + long deadline = System.nanoTime() + STARTUP_TIMEOUT.toNanos(); + HttpRequest request = HttpRequest.newBuilder(URI.create(baseUrl + "/")) + .timeout(Duration.ofSeconds(3)) + .GET() + .build(); + + while (System.nanoTime() < deadline) { + if (!process.isAlive()) { + throw new IllegalStateException( + "Application exited before becoming ready.\n" + tailLog()); + } + + try { + HttpResponse response = HTTP_CLIENT.send( + request, HttpResponse.BodyHandlers.discarding()); + if (response.statusCode() < 500) { + return; + } + } catch (IOException ignored) { + // The server is still starting. + } + + Thread.sleep(500); + } + + throw new IllegalStateException( + "Application did not become ready within " + STARTUP_TIMEOUT + ".\n" + tailLog()); + } + + private String tailLog() { + try { + List lines = Files.readAllLines(logFile, StandardCharsets.UTF_8); + int start = Math.max(0, lines.size() - 100); + return "Server log: " + logFile + "\n" + String.join("\n", lines.subList(start, lines.size())); + } catch (IOException exception) { + return "Could not read server log " + logFile + ": " + exception.getMessage(); + } + } + + @Override + public void close() { + if (!process.isAlive()) { + return; + } + + process.descendants().forEach(ProcessHandle::destroy); + process.destroy(); + try { + if (!process.waitFor(10, TimeUnit.SECONDS)) { + process.descendants().forEach(ProcessHandle::destroyForcibly); + process.destroyForcibly(); + process.waitFor(5, TimeUnit.SECONDS); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + process.destroyForcibly(); + } + } +} diff --git a/2-working-with-data/src/test/java/com/webforj/tutorial/VisualAssertions.java b/2-working-with-data/src/test/java/com/webforj/tutorial/VisualAssertions.java new file mode 100644 index 0000000..c42d6b9 --- /dev/null +++ b/2-working-with-data/src/test/java/com/webforj/tutorial/VisualAssertions.java @@ -0,0 +1,198 @@ +package com.webforj.tutorial; + +import static org.junit.jupiter.api.Assertions.fail; + +import com.microsoft.playwright.Page; +import com.microsoft.playwright.options.ScreenshotAnimations; +import com.microsoft.playwright.options.ScreenshotCaret; +import com.microsoft.playwright.options.ScreenshotScale; +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import javax.imageio.ImageIO; + +final class VisualAssertions { + private static final int DEFAULT_MAX_DIFFERENT_PIXELS = 500; + private static final int CHANNEL_THRESHOLD = 51; + private static final int DEFAULT_TIMEOUT_MS = 10_000; + private static final int POLL_INTERVAL_MS = 250; + + /** + * Every {@code dwc-icon} resolves its artwork over the network and injects an {@code } into + * its own shadow root, so an unresolved icon is invisible in a screenshot while the surrounding + * DOM already looks complete. Icons live inside nested shadow roots, hence the manual walk. + */ + private static final String ICONS_RENDERED = """ + () => { + const icons = []; + const walk = (root) => { + for (const element of root.querySelectorAll('*')) { + if (element.localName === 'dwc-icon') { + icons.push(element); + } + if (element.shadowRoot) { + walk(element.shadowRoot); + } + } + }; + walk(document); + return icons.every(icon => icon.shadowRoot && icon.shadowRoot.querySelector('svg')); + } + """; + + private VisualAssertions() { + } + + static void assertScreenshot(Page page, String name) throws IOException { + requirePngName(name); + awaitRenderedIcons(page); + + Path baselineDirectory = Path.of(requiredProperty("visual.baseline.dir")); + Path artifactDirectory = Path.of(requiredProperty("visual.artifact.dir")); + Path baseline = baselineDirectory.resolve(name); + byte[] screenshot = captureStable(page, name, artifactDirectory); + + if (Boolean.getBoolean("updateScreenshots")) { + requireDocker(); + Files.createDirectories(baselineDirectory); + Files.write(baseline, screenshot); + return; + } + + if (!Files.exists(baseline)) { + fail("Missing Docker screenshot baseline " + baseline + + ". Generate it with `mvn verify -DupdateScreenshots=true`."); + } + + BufferedImage expected = ImageIO.read(baseline.toFile()); + if (expected == null) { + fail("Could not decode screenshot baseline for " + name); + } + + BufferedImage actual = ImageIO.read(new ByteArrayInputStream(screenshot)); + if (actual == null) { + fail("Could not decode captured screenshot for " + name); + } + + if (expected.getWidth() != actual.getWidth() || expected.getHeight() != actual.getHeight()) { + writeFailureArtifacts(artifactDirectory, name, screenshot, null); + fail("Screenshot dimensions differ for " + name + ": expected " + + expected.getWidth() + "x" + expected.getHeight() + ", actual " + + actual.getWidth() + "x" + actual.getHeight()); + } + + BufferedImage diff = new BufferedImage( + actual.getWidth(), actual.getHeight(), BufferedImage.TYPE_INT_ARGB); + int differentPixels = comparePixels(expected, actual, diff); + int maximum = Integer.getInteger("visual.maxDiffPixels", DEFAULT_MAX_DIFFERENT_PIXELS); + if (differentPixels > maximum) { + writeFailureArtifacts(artifactDirectory, name, screenshot, diff); + fail("Screenshot mismatch for " + name + ": " + differentPixels + + " pixels differ (maximum " + maximum + "). See " + artifactDirectory); + } + } + + private static void awaitRenderedIcons(Page page) { + try { + page.waitForFunction(ICONS_RENDERED, null, + new Page.WaitForFunctionOptions().setTimeout(timeoutMs())); + } catch (RuntimeException e) { + fail("Icons did not finish rendering within " + timeoutMs() + + " ms; screenshots would capture missing icons. Icon artwork is fetched from" + + " cdn.jsdelivr.net, so check network access from the container.", e); + } + } + + private static byte[] capture(Page page) { + page.evaluate("() => document.fonts.ready"); + return page.screenshot(new Page.ScreenshotOptions() + .setAnimations(ScreenshotAnimations.DISABLED) + .setCaret(ScreenshotCaret.HIDE) + .setScale(ScreenshotScale.CSS)); + } + + private static byte[] captureStable( + Page page, String name, Path artifactDirectory) throws IOException { + long deadline = System.nanoTime() + timeoutMs() * 1_000_000L; + byte[] previous = capture(page); + while (System.nanoTime() < deadline) { + page.waitForTimeout(POLL_INTERVAL_MS); + byte[] current = capture(page); + if (Arrays.equals(previous, current)) { + return current; + } + previous = current; + } + + writeFailureArtifacts(artifactDirectory, name, previous, null); + return fail("Page did not produce two consecutive identical screenshots for " + name + + " within " + timeoutMs() + " ms. Refusing to compare or update an unstable image. See " + + artifactDirectory); + } + + private static int comparePixels(BufferedImage expected, BufferedImage actual, BufferedImage diff) { + int differentPixels = 0; + for (int y = 0; y < actual.getHeight(); y++) { + for (int x = 0; x < actual.getWidth(); x++) { + int expectedRgb = expected.getRGB(x, y); + int actualRgb = actual.getRGB(x, y); + if (isDifferent(expectedRgb, actualRgb)) { + differentPixels++; + diff.setRGB(x, y, Color.MAGENTA.getRGB()); + } else { + Color pixel = new Color(actualRgb, true); + int gray = (pixel.getRed() + pixel.getGreen() + pixel.getBlue()) / 3; + diff.setRGB(x, y, new Color(gray, gray, gray, 110).getRGB()); + } + } + } + return differentPixels; + } + + private static int timeoutMs() { + return Integer.getInteger("visual.timeoutMs", DEFAULT_TIMEOUT_MS); + } + + private static boolean isDifferent(int expectedRgb, int actualRgb) { + Color expected = new Color(expectedRgb, true); + Color actual = new Color(actualRgb, true); + return Math.abs(expected.getRed() - actual.getRed()) > CHANNEL_THRESHOLD + || Math.abs(expected.getGreen() - actual.getGreen()) > CHANNEL_THRESHOLD + || Math.abs(expected.getBlue() - actual.getBlue()) > CHANNEL_THRESHOLD + || Math.abs(expected.getAlpha() - actual.getAlpha()) > CHANNEL_THRESHOLD; + } + + private static void writeFailureArtifacts( + Path artifactDirectory, String name, byte[] screenshot, BufferedImage diff) throws IOException { + Files.createDirectories(artifactDirectory); + String stem = name.substring(0, name.length() - ".png".length()); + Files.write(artifactDirectory.resolve(stem + "-actual.png"), screenshot); + if (diff != null) { + ImageIO.write(diff, "png", artifactDirectory.resolve(stem + "-diff.png").toFile()); + } + } + + private static String requiredProperty(String name) { + String value = System.getProperty(name); + if (value == null || value.isBlank()) { + throw new IllegalStateException("Missing required system property: " + name); + } + return value; + } + + private static void requirePngName(String name) { + if (!name.matches("[a-z0-9-]+\\.png")) { + throw new IllegalArgumentException("Screenshot name must be a simple kebab-case PNG filename: " + name); + } + } + + private static void requireDocker() { + if (!"true".equalsIgnoreCase(System.getenv("E2E_IN_DOCKER"))) { + throw new IllegalStateException("Screenshot baselines may only be generated in Docker."); + } + } +} diff --git a/2-working-with-data/src/test/resources/screenshots/customer-table.png b/2-working-with-data/src/test/resources/screenshots/customer-table.png new file mode 100644 index 0000000..b66f753 Binary files /dev/null and b/2-working-with-data/src/test/resources/screenshots/customer-table.png differ diff --git a/3-routing-and-composites/README.md b/3-routing-and-composites/README.md index 6b97b67..19d53c1 100644 --- a/3-routing-and-composites/README.md +++ b/3-routing-and-composites/README.md @@ -14,6 +14,7 @@ To run the app, ensure the following tools are installed: - Java 21 or higher - BBj 26.02 when running with local BBjServices - Maven +- Docker Desktop (or another Docker engine) for end-to-end tests - A Java IDE (e.g., IntelliJ IDEA, Eclipse, VSCode) - Web browser - Git (recommended) @@ -49,6 +50,24 @@ webforj-tutorial ``` 3. Open your browser and go to [http://localhost:8080](http://localhost:8080). +## End-to-End and Screenshot Tests + +With Docker running, execute this command from this step's directory: + +```sh +mvn verify +``` + +Maven launches the step's Java Playwright tests in the pinned Docker image. Only this step is built and tested. Tests are in `src/test/java/com/webforj/tutorial`, screenshot baselines in `src/test/resources/screenshots`, and reports, traces, and logs under `target`. + +To update this step's baselines after an intentional visual change: + +```sh +mvn verify -DupdateScreenshots=true +``` + +Both test execution and baseline generation happen in Docker. `mvn test` runs unit tests; the end-to-end tests run during `mvn verify`. + ## Project Highlights - **Spring Boot integration:** Autowire Spring beans directly into webforJ views and components. diff --git a/3-routing-and-composites/pom.xml b/3-routing-and-composites/pom.xml index 30873d8..acf6fd6 100644 --- a/3-routing-and-composites/pom.xml +++ b/3-routing-and-composites/pom.xml @@ -18,6 +18,13 @@ 21 21 UTF-8 + 1.50.0 + 5.11.4 + *IT + false + false + ${maven.test.skip} + ${skipTests} @@ -61,7 +68,12 @@ com.microsoft.playwright playwright - 1.49.0 + ${playwright.version} + test + + + org.junit.jupiter + junit-jupiter test @@ -83,22 +95,14 @@ org.springframework.boot spring-boot-maven-plugin - true + + + - maven-failsafe-plugin - - - - integration-test - verify - - - - - false - + org.springframework.boot + spring-boot-maven-plugin @@ -113,5 +117,99 @@ true + + + docker-e2e + + + !docker.e2e.container + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + + docker-e2e + verify + exec + + ${skipITs} + docker + + run + --rm + --init + --ipc=host + --workdir=/app + -e + CI=true + -e + E2E_IN_DOCKER=true + --mount=type=bind,source=${project.basedir},target=/app + -v + webforj-tutorial-e2e-maven-repo:/var/maven/.m2 + -e + MAVEN_CONFIG=/var/maven/.m2 + mcr.microsoft.com/playwright/java:v${playwright.version}-noble + mvn + -B + -ntp + -Dmaven.repo.local=/var/maven/.m2/repository + -Ddocker.e2e.container=true + -DupdateScreenshots=${updateScreenshots} + -Dit.test=${it.test} + clean + verify + + + + + + + + + + + container-e2e + + + docker.e2e.container + true + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.2.5 + + + + integration-test + verify + + + + + true + false + false + + ${project.basedir} + ${project.build.directory}/${project.build.finalName}.jar + ${project.build.directory} + ${project.basedir}/src/test/resources/screenshots + ${project.build.directory}/visual-diffs + + + + + + diff --git a/3-routing-and-composites/src/main/resources/application.properties b/3-routing-and-composites/src/main/resources/application.properties index 18be8f3..695482f 100644 --- a/3-routing-and-composites/src/main/resources/application.properties +++ b/3-routing-and-composites/src/main/resources/application.properties @@ -1,7 +1,10 @@ -# Spring Boot configuration -spring.application.name=CustomerApplication +# webforJ configuration +webforj.entry=com.webforj.tutorial.Application +webforj.debug=true +webforj.devtools.craftforj.enabled=true +webforj.devtools.livereload.enabled=true -# Server configuration +# Hot reload configuration server.shutdown=immediate server.port=8080 @@ -15,15 +18,7 @@ webforj.entry = com.webforj.tutorial.Application # H2 Database configuration spring.datasource.url=jdbc:h2:mem:testdb -spring.datasource.driverClassName=org.h2.Driver -spring.datasource.username=sa -spring.datasource.password= # JPA configuration -spring.jpa.database-platform=org.hibernate.dialect.H2Dialect spring.jpa.hibernate.ddl-auto=update -# H2 Console (optional - for development) -spring.h2.console.enabled=true -spring.h2.console.path=/h2-console - diff --git a/3-routing-and-composites/src/test/java/com/webforj/tutorial/BaseTest.java b/3-routing-and-composites/src/test/java/com/webforj/tutorial/BaseTest.java new file mode 100644 index 0000000..f34110d --- /dev/null +++ b/3-routing-and-composites/src/test/java/com/webforj/tutorial/BaseTest.java @@ -0,0 +1,155 @@ +package com.webforj.tutorial; + +import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.microsoft.playwright.Browser; +import com.microsoft.playwright.BrowserContext; +import com.microsoft.playwright.BrowserType; +import com.microsoft.playwright.ConsoleMessage; +import com.microsoft.playwright.Locator; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.Playwright; +import com.microsoft.playwright.Request; +import com.microsoft.playwright.Tracing; +import com.microsoft.playwright.assertions.PlaywrightAssertions; +import com.microsoft.playwright.options.AriaRole; +import com.microsoft.playwright.options.ColorScheme; +import com.microsoft.playwright.options.WaitUntilState; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestInfo; +import org.junit.jupiter.api.TestInstance; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +abstract class BaseTest { + private Playwright playwright; + private Browser browser; + private BrowserContext context; + private TutorialApp application; + private final List browserErrors = new ArrayList<>(); + + protected Page page; + + @BeforeAll + void startBrowser() { + TutorialApp.requireDocker(); + playwright = Playwright.create(); + browser = playwright.chromium().launch(new BrowserType.LaunchOptions().setHeadless(true)); + PlaywrightAssertions.setDefaultAssertionTimeout(10_000); + } + + @BeforeEach + void startApplicationAndCreateBrowserContext() throws Exception { + browserErrors.clear(); + application = TutorialApp.start(); + context = browser.newContext(new Browser.NewContextOptions() + .setViewportSize(1440, 900) + .setDeviceScaleFactor(1) + .setColorScheme(ColorScheme.LIGHT) + .setLocale("en-US") + .setTimezoneId("UTC") + .setIgnoreHTTPSErrors(true)); + context.tracing().start(new Tracing.StartOptions() + .setScreenshots(true) + .setSnapshots(true) + .setSources(true)); + + page = context.newPage(); + page.onPageError(error -> browserErrors.add("pageerror: " + error)); + page.onConsoleMessage(this::recordConsoleError); + page.onRequestFailed(this::recordRequestFailure); + } + + @AfterEach + void closeBrowserContext(TestInfo testInfo) throws IOException { + List errorsBeforeClose = List.copyOf(browserErrors); + try { + if (context != null) { + Path traceDirectory = Path.of(System.getProperty("e2e.output.dir"), "playwright-traces"); + Files.createDirectories(traceDirectory); + String testName = (getClass().getSimpleName() + "-" + testInfo.getDisplayName()) + .replaceAll("[^a-zA-Z0-9.-]", "-"); + context.tracing().stop(new Tracing.StopOptions() + .setPath(traceDirectory.resolve(testName + ".zip"))); + context.close(); + context = null; + } + } finally { + if (application != null) { + application.close(); + application = null; + } + } + assertTrue(errorsBeforeClose.isEmpty(), + () -> "Browser runtime/network errors:\n" + String.join("\n", errorsBeforeClose)); + } + + @AfterAll + void stopBrowser() { + if (browser != null) { + browser.close(); + } + if (playwright != null) { + playwright.close(); + } + } + + protected void openApplication() { + openApplication("/"); + } + + protected void openApplication(String path) { + String normalizedPath = path.startsWith("/") ? path : "/" + path; + page.navigate(application.baseUrl() + normalizedPath, + new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED)); + } + + protected Locator customerTable() { + return page.locator("dwc-table"); + } + + protected Locator expectCustomerTable() { + Locator table = customerTable(); + assertThat(table).isVisible(); + assertThat(table).containsText("First Name"); + assertThat(table).containsText("Last Name"); + assertThat(table).containsText("Company"); + assertThat(table).containsText("Country"); + assertThat(table).containsText("Alice"); + assertThat(table).containsText("TechCorp"); + return table; + } + + protected Locator expectCustomerRow(String identifyingCellText) { + Locator identifyingCell = page.getByRole(AriaRole.CELL, + new Page.GetByRoleOptions().setName(identifyingCellText).setExact(true)); + Locator row = page.getByRole(AriaRole.ROW) + .filter(new Locator.FilterOptions().setHas(identifyingCell)); + assertThat(row).hasCount(1); + return row; + } + + private void recordConsoleError(ConsoleMessage message) { + if ("error".equals(message.type())) { + browserErrors.add("console: " + message.text()); + } + } + + private void recordRequestFailure(Request request) { + String failure = request.failure(); + boolean cancelledWebforjPoll = "net::ERR_ABORTED".equals(failure) + && request.url().contains("/webforjServlet/webapprmi"); + if (!cancelledWebforjPoll) { + browserErrors.add( + "requestfailed: " + request.method() + " " + request.url() + " (" + failure + ")"); + } + } +} diff --git a/3-routing-and-composites/src/test/java/com/webforj/tutorial/Step3IT.java b/3-routing-and-composites/src/test/java/com/webforj/tutorial/Step3IT.java new file mode 100644 index 0000000..dd5f62a --- /dev/null +++ b/3-routing-and-composites/src/test/java/com/webforj/tutorial/Step3IT.java @@ -0,0 +1,43 @@ +package com.webforj.tutorial; + +import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; + +import com.microsoft.playwright.Locator; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.options.AriaRole; +import java.io.IOException; +import java.util.regex.Pattern; +import org.junit.jupiter.api.Test; + +class Step3IT extends BaseTest { + @Test + void createsCustomerAndReturnsToTable() throws IOException { + openApplication(); + expectCustomerTable(); + + page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions() + .setName("Add Customer")).click(); + assertThat(page).hasURL(Pattern.compile("/customer$")); + assertThat(page.getByRole(AriaRole.BUTTON, + new Page.GetByRoleOptions().setName("Submit"))).isVisible(); + assertThat(page.getByRole(AriaRole.BUTTON, + new Page.GetByRoleOptions().setName("Cancel"))).isVisible(); + + page.getByLabel("First Name").fill("Ada"); + page.getByLabel("Last Name").fill("Lovelace"); + page.getByLabel("Company").fill("Analytical Engines"); + VisualAssertions.assertScreenshot(page, "completed-customer-form.png"); + + page.getByRole(AriaRole.BUTTON, + new Page.GetByRoleOptions().setName("Submit")).click(); + assertThat(page).hasURL(Pattern.compile("/$")); + Locator adaRow = expectCustomerRow("Ada"); + assertThat(adaRow).containsText("Analytical Engines"); + + page.getByRole(AriaRole.BUTTON, + new Page.GetByRoleOptions().setName("Add Customer")).click(); + page.getByRole(AriaRole.BUTTON, + new Page.GetByRoleOptions().setName("Cancel")).click(); + assertThat(page).hasURL(Pattern.compile("/$")); + } +} diff --git a/3-routing-and-composites/src/test/java/com/webforj/tutorial/TutorialApp.java b/3-routing-and-composites/src/test/java/com/webforj/tutorial/TutorialApp.java new file mode 100644 index 0000000..9d3ae51 --- /dev/null +++ b/3-routing-and-composites/src/test/java/com/webforj/tutorial/TutorialApp.java @@ -0,0 +1,159 @@ +package com.webforj.tutorial; + +import java.io.IOException; +import java.net.ServerSocket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.TimeUnit; + +final class TutorialApp implements AutoCloseable { + private static final Duration STARTUP_TIMEOUT = Duration.ofMinutes(3); + private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(2)) + .build(); + + private final Process process; + private final String baseUrl; + private final Path logFile; + + private TutorialApp(Process process, String baseUrl, Path logFile) { + this.process = process; + this.baseUrl = baseUrl; + this.logFile = logFile; + } + + static TutorialApp start() throws Exception { + requireDocker(); + + Path stepPath = Path.of(System.getProperty("e2e.app.dir")).toAbsolutePath().normalize(); + Path jar = Path.of(System.getProperty("e2e.app.jar")).toAbsolutePath().normalize(); + if (!Files.isRegularFile(jar)) { + throw new IllegalStateException("Application JAR does not exist: " + jar + + ". Run the complete Maven verify lifecycle to package the application first."); + } + + int port = findFreePort(); + String baseUrl = "http://127.0.0.1:" + port; + Path logFile = Path.of(System.getProperty("e2e.output.dir"), "e2e-artifacts", "server.log"); + Files.createDirectories(logFile.getParent()); + + String javaExecutable = Path.of(System.getProperty("java.home"), "bin", "java").toString(); + ProcessBuilder processBuilder = new ProcessBuilder( + javaExecutable, + "-jar", + jar.toString(), + "--server.port=" + port, + "--server.address=127.0.0.1", + "--spring.jpa.hibernate.ddl-auto=create-drop", + "--webforj.devtools.browser.open=false", + "--webforj.devtools.livereload.enabled=false", + "--webforj.devtools.livereload.static-resources-enabled=false", + "--webforj.devtools.craftforj.enabled=false", + "--webforj.debug=false"); + processBuilder.directory(stepPath.toFile()); + processBuilder.redirectErrorStream(true); + processBuilder.redirectOutput(logFile.toFile()); + + Process process = processBuilder.start(); + TutorialApp app = new TutorialApp(process, baseUrl, logFile); + try { + app.waitUntilReady(); + return app; + } catch (Exception exception) { + app.close(); + throw exception; + } + } + + String baseUrl() { + return baseUrl; + } + + static void requireDocker() { + if (!"true".equalsIgnoreCase(System.getenv("E2E_IN_DOCKER"))) { + throw new IllegalStateException( + "E2E tests may only run in Docker. Run `mvn verify` from this tutorial step's directory."); + } + } + + private static int findFreePort() throws IOException { + for (int preferredPort : List.of(8080, 8090)) { + try (ServerSocket ignored = new ServerSocket(preferredPort)) { + return preferredPort; + } catch (IOException ignored) { + // Try the next preferred port. + } + } + + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } + + private void waitUntilReady() throws Exception { + long deadline = System.nanoTime() + STARTUP_TIMEOUT.toNanos(); + HttpRequest request = HttpRequest.newBuilder(URI.create(baseUrl + "/")) + .timeout(Duration.ofSeconds(3)) + .GET() + .build(); + + while (System.nanoTime() < deadline) { + if (!process.isAlive()) { + throw new IllegalStateException( + "Application exited before becoming ready.\n" + tailLog()); + } + + try { + HttpResponse response = HTTP_CLIENT.send( + request, HttpResponse.BodyHandlers.discarding()); + if (response.statusCode() < 500) { + return; + } + } catch (IOException ignored) { + // The server is still starting. + } + + Thread.sleep(500); + } + + throw new IllegalStateException( + "Application did not become ready within " + STARTUP_TIMEOUT + ".\n" + tailLog()); + } + + private String tailLog() { + try { + List lines = Files.readAllLines(logFile, StandardCharsets.UTF_8); + int start = Math.max(0, lines.size() - 100); + return "Server log: " + logFile + "\n" + String.join("\n", lines.subList(start, lines.size())); + } catch (IOException exception) { + return "Could not read server log " + logFile + ": " + exception.getMessage(); + } + } + + @Override + public void close() { + if (!process.isAlive()) { + return; + } + + process.descendants().forEach(ProcessHandle::destroy); + process.destroy(); + try { + if (!process.waitFor(10, TimeUnit.SECONDS)) { + process.descendants().forEach(ProcessHandle::destroyForcibly); + process.destroyForcibly(); + process.waitFor(5, TimeUnit.SECONDS); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + process.destroyForcibly(); + } + } +} diff --git a/3-routing-and-composites/src/test/java/com/webforj/tutorial/VisualAssertions.java b/3-routing-and-composites/src/test/java/com/webforj/tutorial/VisualAssertions.java new file mode 100644 index 0000000..c42d6b9 --- /dev/null +++ b/3-routing-and-composites/src/test/java/com/webforj/tutorial/VisualAssertions.java @@ -0,0 +1,198 @@ +package com.webforj.tutorial; + +import static org.junit.jupiter.api.Assertions.fail; + +import com.microsoft.playwright.Page; +import com.microsoft.playwright.options.ScreenshotAnimations; +import com.microsoft.playwright.options.ScreenshotCaret; +import com.microsoft.playwright.options.ScreenshotScale; +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import javax.imageio.ImageIO; + +final class VisualAssertions { + private static final int DEFAULT_MAX_DIFFERENT_PIXELS = 500; + private static final int CHANNEL_THRESHOLD = 51; + private static final int DEFAULT_TIMEOUT_MS = 10_000; + private static final int POLL_INTERVAL_MS = 250; + + /** + * Every {@code dwc-icon} resolves its artwork over the network and injects an {@code } into + * its own shadow root, so an unresolved icon is invisible in a screenshot while the surrounding + * DOM already looks complete. Icons live inside nested shadow roots, hence the manual walk. + */ + private static final String ICONS_RENDERED = """ + () => { + const icons = []; + const walk = (root) => { + for (const element of root.querySelectorAll('*')) { + if (element.localName === 'dwc-icon') { + icons.push(element); + } + if (element.shadowRoot) { + walk(element.shadowRoot); + } + } + }; + walk(document); + return icons.every(icon => icon.shadowRoot && icon.shadowRoot.querySelector('svg')); + } + """; + + private VisualAssertions() { + } + + static void assertScreenshot(Page page, String name) throws IOException { + requirePngName(name); + awaitRenderedIcons(page); + + Path baselineDirectory = Path.of(requiredProperty("visual.baseline.dir")); + Path artifactDirectory = Path.of(requiredProperty("visual.artifact.dir")); + Path baseline = baselineDirectory.resolve(name); + byte[] screenshot = captureStable(page, name, artifactDirectory); + + if (Boolean.getBoolean("updateScreenshots")) { + requireDocker(); + Files.createDirectories(baselineDirectory); + Files.write(baseline, screenshot); + return; + } + + if (!Files.exists(baseline)) { + fail("Missing Docker screenshot baseline " + baseline + + ". Generate it with `mvn verify -DupdateScreenshots=true`."); + } + + BufferedImage expected = ImageIO.read(baseline.toFile()); + if (expected == null) { + fail("Could not decode screenshot baseline for " + name); + } + + BufferedImage actual = ImageIO.read(new ByteArrayInputStream(screenshot)); + if (actual == null) { + fail("Could not decode captured screenshot for " + name); + } + + if (expected.getWidth() != actual.getWidth() || expected.getHeight() != actual.getHeight()) { + writeFailureArtifacts(artifactDirectory, name, screenshot, null); + fail("Screenshot dimensions differ for " + name + ": expected " + + expected.getWidth() + "x" + expected.getHeight() + ", actual " + + actual.getWidth() + "x" + actual.getHeight()); + } + + BufferedImage diff = new BufferedImage( + actual.getWidth(), actual.getHeight(), BufferedImage.TYPE_INT_ARGB); + int differentPixels = comparePixels(expected, actual, diff); + int maximum = Integer.getInteger("visual.maxDiffPixels", DEFAULT_MAX_DIFFERENT_PIXELS); + if (differentPixels > maximum) { + writeFailureArtifacts(artifactDirectory, name, screenshot, diff); + fail("Screenshot mismatch for " + name + ": " + differentPixels + + " pixels differ (maximum " + maximum + "). See " + artifactDirectory); + } + } + + private static void awaitRenderedIcons(Page page) { + try { + page.waitForFunction(ICONS_RENDERED, null, + new Page.WaitForFunctionOptions().setTimeout(timeoutMs())); + } catch (RuntimeException e) { + fail("Icons did not finish rendering within " + timeoutMs() + + " ms; screenshots would capture missing icons. Icon artwork is fetched from" + + " cdn.jsdelivr.net, so check network access from the container.", e); + } + } + + private static byte[] capture(Page page) { + page.evaluate("() => document.fonts.ready"); + return page.screenshot(new Page.ScreenshotOptions() + .setAnimations(ScreenshotAnimations.DISABLED) + .setCaret(ScreenshotCaret.HIDE) + .setScale(ScreenshotScale.CSS)); + } + + private static byte[] captureStable( + Page page, String name, Path artifactDirectory) throws IOException { + long deadline = System.nanoTime() + timeoutMs() * 1_000_000L; + byte[] previous = capture(page); + while (System.nanoTime() < deadline) { + page.waitForTimeout(POLL_INTERVAL_MS); + byte[] current = capture(page); + if (Arrays.equals(previous, current)) { + return current; + } + previous = current; + } + + writeFailureArtifacts(artifactDirectory, name, previous, null); + return fail("Page did not produce two consecutive identical screenshots for " + name + + " within " + timeoutMs() + " ms. Refusing to compare or update an unstable image. See " + + artifactDirectory); + } + + private static int comparePixels(BufferedImage expected, BufferedImage actual, BufferedImage diff) { + int differentPixels = 0; + for (int y = 0; y < actual.getHeight(); y++) { + for (int x = 0; x < actual.getWidth(); x++) { + int expectedRgb = expected.getRGB(x, y); + int actualRgb = actual.getRGB(x, y); + if (isDifferent(expectedRgb, actualRgb)) { + differentPixels++; + diff.setRGB(x, y, Color.MAGENTA.getRGB()); + } else { + Color pixel = new Color(actualRgb, true); + int gray = (pixel.getRed() + pixel.getGreen() + pixel.getBlue()) / 3; + diff.setRGB(x, y, new Color(gray, gray, gray, 110).getRGB()); + } + } + } + return differentPixels; + } + + private static int timeoutMs() { + return Integer.getInteger("visual.timeoutMs", DEFAULT_TIMEOUT_MS); + } + + private static boolean isDifferent(int expectedRgb, int actualRgb) { + Color expected = new Color(expectedRgb, true); + Color actual = new Color(actualRgb, true); + return Math.abs(expected.getRed() - actual.getRed()) > CHANNEL_THRESHOLD + || Math.abs(expected.getGreen() - actual.getGreen()) > CHANNEL_THRESHOLD + || Math.abs(expected.getBlue() - actual.getBlue()) > CHANNEL_THRESHOLD + || Math.abs(expected.getAlpha() - actual.getAlpha()) > CHANNEL_THRESHOLD; + } + + private static void writeFailureArtifacts( + Path artifactDirectory, String name, byte[] screenshot, BufferedImage diff) throws IOException { + Files.createDirectories(artifactDirectory); + String stem = name.substring(0, name.length() - ".png".length()); + Files.write(artifactDirectory.resolve(stem + "-actual.png"), screenshot); + if (diff != null) { + ImageIO.write(diff, "png", artifactDirectory.resolve(stem + "-diff.png").toFile()); + } + } + + private static String requiredProperty(String name) { + String value = System.getProperty(name); + if (value == null || value.isBlank()) { + throw new IllegalStateException("Missing required system property: " + name); + } + return value; + } + + private static void requirePngName(String name) { + if (!name.matches("[a-z0-9-]+\\.png")) { + throw new IllegalArgumentException("Screenshot name must be a simple kebab-case PNG filename: " + name); + } + } + + private static void requireDocker() { + if (!"true".equalsIgnoreCase(System.getenv("E2E_IN_DOCKER"))) { + throw new IllegalStateException("Screenshot baselines may only be generated in Docker."); + } + } +} diff --git a/3-routing-and-composites/src/test/resources/screenshots/completed-customer-form.png b/3-routing-and-composites/src/test/resources/screenshots/completed-customer-form.png new file mode 100644 index 0000000..8cb5f1e Binary files /dev/null and b/3-routing-and-composites/src/test/resources/screenshots/completed-customer-form.png differ diff --git a/4-observers-and-route-parameters/README.md b/4-observers-and-route-parameters/README.md index 6b97b67..19d53c1 100644 --- a/4-observers-and-route-parameters/README.md +++ b/4-observers-and-route-parameters/README.md @@ -14,6 +14,7 @@ To run the app, ensure the following tools are installed: - Java 21 or higher - BBj 26.02 when running with local BBjServices - Maven +- Docker Desktop (or another Docker engine) for end-to-end tests - A Java IDE (e.g., IntelliJ IDEA, Eclipse, VSCode) - Web browser - Git (recommended) @@ -49,6 +50,24 @@ webforj-tutorial ``` 3. Open your browser and go to [http://localhost:8080](http://localhost:8080). +## End-to-End and Screenshot Tests + +With Docker running, execute this command from this step's directory: + +```sh +mvn verify +``` + +Maven launches the step's Java Playwright tests in the pinned Docker image. Only this step is built and tested. Tests are in `src/test/java/com/webforj/tutorial`, screenshot baselines in `src/test/resources/screenshots`, and reports, traces, and logs under `target`. + +To update this step's baselines after an intentional visual change: + +```sh +mvn verify -DupdateScreenshots=true +``` + +Both test execution and baseline generation happen in Docker. `mvn test` runs unit tests; the end-to-end tests run during `mvn verify`. + ## Project Highlights - **Spring Boot integration:** Autowire Spring beans directly into webforJ views and components. diff --git a/4-observers-and-route-parameters/pom.xml b/4-observers-and-route-parameters/pom.xml index 3b19242..690d928 100644 --- a/4-observers-and-route-parameters/pom.xml +++ b/4-observers-and-route-parameters/pom.xml @@ -18,6 +18,13 @@ 21 21 UTF-8 + 1.50.0 + 5.11.4 + *IT + false + false + ${maven.test.skip} + ${skipTests} @@ -61,7 +68,12 @@ com.microsoft.playwright playwright - 1.49.0 + ${playwright.version} + test + + + org.junit.jupiter + junit-jupiter test @@ -83,22 +95,14 @@ org.springframework.boot spring-boot-maven-plugin - true + + + - maven-failsafe-plugin - - - - integration-test - verify - - - - - false - + org.springframework.boot + spring-boot-maven-plugin @@ -113,5 +117,99 @@ true + + + docker-e2e + + + !docker.e2e.container + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + + docker-e2e + verify + exec + + ${skipITs} + docker + + run + --rm + --init + --ipc=host + --workdir=/app + -e + CI=true + -e + E2E_IN_DOCKER=true + --mount=type=bind,source=${project.basedir},target=/app + -v + webforj-tutorial-e2e-maven-repo:/var/maven/.m2 + -e + MAVEN_CONFIG=/var/maven/.m2 + mcr.microsoft.com/playwright/java:v${playwright.version}-noble + mvn + -B + -ntp + -Dmaven.repo.local=/var/maven/.m2/repository + -Ddocker.e2e.container=true + -DupdateScreenshots=${updateScreenshots} + -Dit.test=${it.test} + clean + verify + + + + + + + + + + + container-e2e + + + docker.e2e.container + true + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.2.5 + + + + integration-test + verify + + + + + true + false + false + + ${project.basedir} + ${project.build.directory}/${project.build.finalName}.jar + ${project.build.directory} + ${project.basedir}/src/test/resources/screenshots + ${project.build.directory}/visual-diffs + + + + + + diff --git a/4-observers-and-route-parameters/src/main/resources/application.properties b/4-observers-and-route-parameters/src/main/resources/application.properties index 18be8f3..695482f 100644 --- a/4-observers-and-route-parameters/src/main/resources/application.properties +++ b/4-observers-and-route-parameters/src/main/resources/application.properties @@ -1,7 +1,10 @@ -# Spring Boot configuration -spring.application.name=CustomerApplication +# webforJ configuration +webforj.entry=com.webforj.tutorial.Application +webforj.debug=true +webforj.devtools.craftforj.enabled=true +webforj.devtools.livereload.enabled=true -# Server configuration +# Hot reload configuration server.shutdown=immediate server.port=8080 @@ -15,15 +18,7 @@ webforj.entry = com.webforj.tutorial.Application # H2 Database configuration spring.datasource.url=jdbc:h2:mem:testdb -spring.datasource.driverClassName=org.h2.Driver -spring.datasource.username=sa -spring.datasource.password= # JPA configuration -spring.jpa.database-platform=org.hibernate.dialect.H2Dialect spring.jpa.hibernate.ddl-auto=update -# H2 Console (optional - for development) -spring.h2.console.enabled=true -spring.h2.console.path=/h2-console - diff --git a/4-observers-and-route-parameters/src/test/java/com/webforj/tutorial/BaseTest.java b/4-observers-and-route-parameters/src/test/java/com/webforj/tutorial/BaseTest.java new file mode 100644 index 0000000..f34110d --- /dev/null +++ b/4-observers-and-route-parameters/src/test/java/com/webforj/tutorial/BaseTest.java @@ -0,0 +1,155 @@ +package com.webforj.tutorial; + +import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.microsoft.playwright.Browser; +import com.microsoft.playwright.BrowserContext; +import com.microsoft.playwright.BrowserType; +import com.microsoft.playwright.ConsoleMessage; +import com.microsoft.playwright.Locator; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.Playwright; +import com.microsoft.playwright.Request; +import com.microsoft.playwright.Tracing; +import com.microsoft.playwright.assertions.PlaywrightAssertions; +import com.microsoft.playwright.options.AriaRole; +import com.microsoft.playwright.options.ColorScheme; +import com.microsoft.playwright.options.WaitUntilState; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestInfo; +import org.junit.jupiter.api.TestInstance; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +abstract class BaseTest { + private Playwright playwright; + private Browser browser; + private BrowserContext context; + private TutorialApp application; + private final List browserErrors = new ArrayList<>(); + + protected Page page; + + @BeforeAll + void startBrowser() { + TutorialApp.requireDocker(); + playwright = Playwright.create(); + browser = playwright.chromium().launch(new BrowserType.LaunchOptions().setHeadless(true)); + PlaywrightAssertions.setDefaultAssertionTimeout(10_000); + } + + @BeforeEach + void startApplicationAndCreateBrowserContext() throws Exception { + browserErrors.clear(); + application = TutorialApp.start(); + context = browser.newContext(new Browser.NewContextOptions() + .setViewportSize(1440, 900) + .setDeviceScaleFactor(1) + .setColorScheme(ColorScheme.LIGHT) + .setLocale("en-US") + .setTimezoneId("UTC") + .setIgnoreHTTPSErrors(true)); + context.tracing().start(new Tracing.StartOptions() + .setScreenshots(true) + .setSnapshots(true) + .setSources(true)); + + page = context.newPage(); + page.onPageError(error -> browserErrors.add("pageerror: " + error)); + page.onConsoleMessage(this::recordConsoleError); + page.onRequestFailed(this::recordRequestFailure); + } + + @AfterEach + void closeBrowserContext(TestInfo testInfo) throws IOException { + List errorsBeforeClose = List.copyOf(browserErrors); + try { + if (context != null) { + Path traceDirectory = Path.of(System.getProperty("e2e.output.dir"), "playwright-traces"); + Files.createDirectories(traceDirectory); + String testName = (getClass().getSimpleName() + "-" + testInfo.getDisplayName()) + .replaceAll("[^a-zA-Z0-9.-]", "-"); + context.tracing().stop(new Tracing.StopOptions() + .setPath(traceDirectory.resolve(testName + ".zip"))); + context.close(); + context = null; + } + } finally { + if (application != null) { + application.close(); + application = null; + } + } + assertTrue(errorsBeforeClose.isEmpty(), + () -> "Browser runtime/network errors:\n" + String.join("\n", errorsBeforeClose)); + } + + @AfterAll + void stopBrowser() { + if (browser != null) { + browser.close(); + } + if (playwright != null) { + playwright.close(); + } + } + + protected void openApplication() { + openApplication("/"); + } + + protected void openApplication(String path) { + String normalizedPath = path.startsWith("/") ? path : "/" + path; + page.navigate(application.baseUrl() + normalizedPath, + new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED)); + } + + protected Locator customerTable() { + return page.locator("dwc-table"); + } + + protected Locator expectCustomerTable() { + Locator table = customerTable(); + assertThat(table).isVisible(); + assertThat(table).containsText("First Name"); + assertThat(table).containsText("Last Name"); + assertThat(table).containsText("Company"); + assertThat(table).containsText("Country"); + assertThat(table).containsText("Alice"); + assertThat(table).containsText("TechCorp"); + return table; + } + + protected Locator expectCustomerRow(String identifyingCellText) { + Locator identifyingCell = page.getByRole(AriaRole.CELL, + new Page.GetByRoleOptions().setName(identifyingCellText).setExact(true)); + Locator row = page.getByRole(AriaRole.ROW) + .filter(new Locator.FilterOptions().setHas(identifyingCell)); + assertThat(row).hasCount(1); + return row; + } + + private void recordConsoleError(ConsoleMessage message) { + if ("error".equals(message.type())) { + browserErrors.add("console: " + message.text()); + } + } + + private void recordRequestFailure(Request request) { + String failure = request.failure(); + boolean cancelledWebforjPoll = "net::ERR_ABORTED".equals(failure) + && request.url().contains("/webforjServlet/webapprmi"); + if (!cancelledWebforjPoll) { + browserErrors.add( + "requestfailed: " + request.method() + " " + request.url() + " (" + failure + ")"); + } + } +} diff --git a/4-observers-and-route-parameters/src/test/java/com/webforj/tutorial/Step4IT.java b/4-observers-and-route-parameters/src/test/java/com/webforj/tutorial/Step4IT.java new file mode 100644 index 0000000..90eebc4 --- /dev/null +++ b/4-observers-and-route-parameters/src/test/java/com/webforj/tutorial/Step4IT.java @@ -0,0 +1,44 @@ +package com.webforj.tutorial; + +import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; + +import com.microsoft.playwright.Page; +import com.microsoft.playwright.options.AriaRole; +import java.io.IOException; +import java.util.regex.Pattern; +import org.junit.jupiter.api.Test; + +class Step4IT extends BaseTest { + @Test + void showsRoutedCustomerFormConsistently() throws IOException { + openApplication("/customer/1"); + assertThat(page.getByLabel("First Name")).hasValue("Alice"); + assertThat(page.getByLabel("Last Name")).hasValue("Smith"); + assertThat(page.getByLabel("Company")).hasValue("TechCorp"); + VisualAssertions.assertScreenshot(page, "edit-customer-route.png"); + } + + @Test + void loadsCustomerFromRouteAndSavesEdits() { + openApplication(); + expectCustomerTable(); + + page.getByText("John", new Page.GetByTextOptions().setExact(true)).click(); + assertThat(page).hasURL(Pattern.compile("/customer/2$")); + assertThat(page.getByLabel("First Name")).hasValue("John"); + assertThat(page.getByLabel("Last Name")).hasValue("Doe"); + + var company = page.getByLabel("Company"); + company.click(); + company.press("Control+A"); + company.pressSequentially("Updated Innovatech"); + assertThat(company).hasValue("Updated Innovatech"); + page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Submit")).click(); + assertThat(page).hasURL(Pattern.compile("/$")); + assertThat(expectCustomerRow("John")).containsText("Updated Innovatech"); + + openApplication("/customer/999999"); + assertThat(page).hasURL(Pattern.compile("/$")); + assertThat(customerTable()).isVisible(); + } +} diff --git a/4-observers-and-route-parameters/src/test/java/com/webforj/tutorial/TutorialApp.java b/4-observers-and-route-parameters/src/test/java/com/webforj/tutorial/TutorialApp.java new file mode 100644 index 0000000..9d3ae51 --- /dev/null +++ b/4-observers-and-route-parameters/src/test/java/com/webforj/tutorial/TutorialApp.java @@ -0,0 +1,159 @@ +package com.webforj.tutorial; + +import java.io.IOException; +import java.net.ServerSocket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.TimeUnit; + +final class TutorialApp implements AutoCloseable { + private static final Duration STARTUP_TIMEOUT = Duration.ofMinutes(3); + private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(2)) + .build(); + + private final Process process; + private final String baseUrl; + private final Path logFile; + + private TutorialApp(Process process, String baseUrl, Path logFile) { + this.process = process; + this.baseUrl = baseUrl; + this.logFile = logFile; + } + + static TutorialApp start() throws Exception { + requireDocker(); + + Path stepPath = Path.of(System.getProperty("e2e.app.dir")).toAbsolutePath().normalize(); + Path jar = Path.of(System.getProperty("e2e.app.jar")).toAbsolutePath().normalize(); + if (!Files.isRegularFile(jar)) { + throw new IllegalStateException("Application JAR does not exist: " + jar + + ". Run the complete Maven verify lifecycle to package the application first."); + } + + int port = findFreePort(); + String baseUrl = "http://127.0.0.1:" + port; + Path logFile = Path.of(System.getProperty("e2e.output.dir"), "e2e-artifacts", "server.log"); + Files.createDirectories(logFile.getParent()); + + String javaExecutable = Path.of(System.getProperty("java.home"), "bin", "java").toString(); + ProcessBuilder processBuilder = new ProcessBuilder( + javaExecutable, + "-jar", + jar.toString(), + "--server.port=" + port, + "--server.address=127.0.0.1", + "--spring.jpa.hibernate.ddl-auto=create-drop", + "--webforj.devtools.browser.open=false", + "--webforj.devtools.livereload.enabled=false", + "--webforj.devtools.livereload.static-resources-enabled=false", + "--webforj.devtools.craftforj.enabled=false", + "--webforj.debug=false"); + processBuilder.directory(stepPath.toFile()); + processBuilder.redirectErrorStream(true); + processBuilder.redirectOutput(logFile.toFile()); + + Process process = processBuilder.start(); + TutorialApp app = new TutorialApp(process, baseUrl, logFile); + try { + app.waitUntilReady(); + return app; + } catch (Exception exception) { + app.close(); + throw exception; + } + } + + String baseUrl() { + return baseUrl; + } + + static void requireDocker() { + if (!"true".equalsIgnoreCase(System.getenv("E2E_IN_DOCKER"))) { + throw new IllegalStateException( + "E2E tests may only run in Docker. Run `mvn verify` from this tutorial step's directory."); + } + } + + private static int findFreePort() throws IOException { + for (int preferredPort : List.of(8080, 8090)) { + try (ServerSocket ignored = new ServerSocket(preferredPort)) { + return preferredPort; + } catch (IOException ignored) { + // Try the next preferred port. + } + } + + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } + + private void waitUntilReady() throws Exception { + long deadline = System.nanoTime() + STARTUP_TIMEOUT.toNanos(); + HttpRequest request = HttpRequest.newBuilder(URI.create(baseUrl + "/")) + .timeout(Duration.ofSeconds(3)) + .GET() + .build(); + + while (System.nanoTime() < deadline) { + if (!process.isAlive()) { + throw new IllegalStateException( + "Application exited before becoming ready.\n" + tailLog()); + } + + try { + HttpResponse response = HTTP_CLIENT.send( + request, HttpResponse.BodyHandlers.discarding()); + if (response.statusCode() < 500) { + return; + } + } catch (IOException ignored) { + // The server is still starting. + } + + Thread.sleep(500); + } + + throw new IllegalStateException( + "Application did not become ready within " + STARTUP_TIMEOUT + ".\n" + tailLog()); + } + + private String tailLog() { + try { + List lines = Files.readAllLines(logFile, StandardCharsets.UTF_8); + int start = Math.max(0, lines.size() - 100); + return "Server log: " + logFile + "\n" + String.join("\n", lines.subList(start, lines.size())); + } catch (IOException exception) { + return "Could not read server log " + logFile + ": " + exception.getMessage(); + } + } + + @Override + public void close() { + if (!process.isAlive()) { + return; + } + + process.descendants().forEach(ProcessHandle::destroy); + process.destroy(); + try { + if (!process.waitFor(10, TimeUnit.SECONDS)) { + process.descendants().forEach(ProcessHandle::destroyForcibly); + process.destroyForcibly(); + process.waitFor(5, TimeUnit.SECONDS); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + process.destroyForcibly(); + } + } +} diff --git a/4-observers-and-route-parameters/src/test/java/com/webforj/tutorial/VisualAssertions.java b/4-observers-and-route-parameters/src/test/java/com/webforj/tutorial/VisualAssertions.java new file mode 100644 index 0000000..c42d6b9 --- /dev/null +++ b/4-observers-and-route-parameters/src/test/java/com/webforj/tutorial/VisualAssertions.java @@ -0,0 +1,198 @@ +package com.webforj.tutorial; + +import static org.junit.jupiter.api.Assertions.fail; + +import com.microsoft.playwright.Page; +import com.microsoft.playwright.options.ScreenshotAnimations; +import com.microsoft.playwright.options.ScreenshotCaret; +import com.microsoft.playwright.options.ScreenshotScale; +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import javax.imageio.ImageIO; + +final class VisualAssertions { + private static final int DEFAULT_MAX_DIFFERENT_PIXELS = 500; + private static final int CHANNEL_THRESHOLD = 51; + private static final int DEFAULT_TIMEOUT_MS = 10_000; + private static final int POLL_INTERVAL_MS = 250; + + /** + * Every {@code dwc-icon} resolves its artwork over the network and injects an {@code } into + * its own shadow root, so an unresolved icon is invisible in a screenshot while the surrounding + * DOM already looks complete. Icons live inside nested shadow roots, hence the manual walk. + */ + private static final String ICONS_RENDERED = """ + () => { + const icons = []; + const walk = (root) => { + for (const element of root.querySelectorAll('*')) { + if (element.localName === 'dwc-icon') { + icons.push(element); + } + if (element.shadowRoot) { + walk(element.shadowRoot); + } + } + }; + walk(document); + return icons.every(icon => icon.shadowRoot && icon.shadowRoot.querySelector('svg')); + } + """; + + private VisualAssertions() { + } + + static void assertScreenshot(Page page, String name) throws IOException { + requirePngName(name); + awaitRenderedIcons(page); + + Path baselineDirectory = Path.of(requiredProperty("visual.baseline.dir")); + Path artifactDirectory = Path.of(requiredProperty("visual.artifact.dir")); + Path baseline = baselineDirectory.resolve(name); + byte[] screenshot = captureStable(page, name, artifactDirectory); + + if (Boolean.getBoolean("updateScreenshots")) { + requireDocker(); + Files.createDirectories(baselineDirectory); + Files.write(baseline, screenshot); + return; + } + + if (!Files.exists(baseline)) { + fail("Missing Docker screenshot baseline " + baseline + + ". Generate it with `mvn verify -DupdateScreenshots=true`."); + } + + BufferedImage expected = ImageIO.read(baseline.toFile()); + if (expected == null) { + fail("Could not decode screenshot baseline for " + name); + } + + BufferedImage actual = ImageIO.read(new ByteArrayInputStream(screenshot)); + if (actual == null) { + fail("Could not decode captured screenshot for " + name); + } + + if (expected.getWidth() != actual.getWidth() || expected.getHeight() != actual.getHeight()) { + writeFailureArtifacts(artifactDirectory, name, screenshot, null); + fail("Screenshot dimensions differ for " + name + ": expected " + + expected.getWidth() + "x" + expected.getHeight() + ", actual " + + actual.getWidth() + "x" + actual.getHeight()); + } + + BufferedImage diff = new BufferedImage( + actual.getWidth(), actual.getHeight(), BufferedImage.TYPE_INT_ARGB); + int differentPixels = comparePixels(expected, actual, diff); + int maximum = Integer.getInteger("visual.maxDiffPixels", DEFAULT_MAX_DIFFERENT_PIXELS); + if (differentPixels > maximum) { + writeFailureArtifacts(artifactDirectory, name, screenshot, diff); + fail("Screenshot mismatch for " + name + ": " + differentPixels + + " pixels differ (maximum " + maximum + "). See " + artifactDirectory); + } + } + + private static void awaitRenderedIcons(Page page) { + try { + page.waitForFunction(ICONS_RENDERED, null, + new Page.WaitForFunctionOptions().setTimeout(timeoutMs())); + } catch (RuntimeException e) { + fail("Icons did not finish rendering within " + timeoutMs() + + " ms; screenshots would capture missing icons. Icon artwork is fetched from" + + " cdn.jsdelivr.net, so check network access from the container.", e); + } + } + + private static byte[] capture(Page page) { + page.evaluate("() => document.fonts.ready"); + return page.screenshot(new Page.ScreenshotOptions() + .setAnimations(ScreenshotAnimations.DISABLED) + .setCaret(ScreenshotCaret.HIDE) + .setScale(ScreenshotScale.CSS)); + } + + private static byte[] captureStable( + Page page, String name, Path artifactDirectory) throws IOException { + long deadline = System.nanoTime() + timeoutMs() * 1_000_000L; + byte[] previous = capture(page); + while (System.nanoTime() < deadline) { + page.waitForTimeout(POLL_INTERVAL_MS); + byte[] current = capture(page); + if (Arrays.equals(previous, current)) { + return current; + } + previous = current; + } + + writeFailureArtifacts(artifactDirectory, name, previous, null); + return fail("Page did not produce two consecutive identical screenshots for " + name + + " within " + timeoutMs() + " ms. Refusing to compare or update an unstable image. See " + + artifactDirectory); + } + + private static int comparePixels(BufferedImage expected, BufferedImage actual, BufferedImage diff) { + int differentPixels = 0; + for (int y = 0; y < actual.getHeight(); y++) { + for (int x = 0; x < actual.getWidth(); x++) { + int expectedRgb = expected.getRGB(x, y); + int actualRgb = actual.getRGB(x, y); + if (isDifferent(expectedRgb, actualRgb)) { + differentPixels++; + diff.setRGB(x, y, Color.MAGENTA.getRGB()); + } else { + Color pixel = new Color(actualRgb, true); + int gray = (pixel.getRed() + pixel.getGreen() + pixel.getBlue()) / 3; + diff.setRGB(x, y, new Color(gray, gray, gray, 110).getRGB()); + } + } + } + return differentPixels; + } + + private static int timeoutMs() { + return Integer.getInteger("visual.timeoutMs", DEFAULT_TIMEOUT_MS); + } + + private static boolean isDifferent(int expectedRgb, int actualRgb) { + Color expected = new Color(expectedRgb, true); + Color actual = new Color(actualRgb, true); + return Math.abs(expected.getRed() - actual.getRed()) > CHANNEL_THRESHOLD + || Math.abs(expected.getGreen() - actual.getGreen()) > CHANNEL_THRESHOLD + || Math.abs(expected.getBlue() - actual.getBlue()) > CHANNEL_THRESHOLD + || Math.abs(expected.getAlpha() - actual.getAlpha()) > CHANNEL_THRESHOLD; + } + + private static void writeFailureArtifacts( + Path artifactDirectory, String name, byte[] screenshot, BufferedImage diff) throws IOException { + Files.createDirectories(artifactDirectory); + String stem = name.substring(0, name.length() - ".png".length()); + Files.write(artifactDirectory.resolve(stem + "-actual.png"), screenshot); + if (diff != null) { + ImageIO.write(diff, "png", artifactDirectory.resolve(stem + "-diff.png").toFile()); + } + } + + private static String requiredProperty(String name) { + String value = System.getProperty(name); + if (value == null || value.isBlank()) { + throw new IllegalStateException("Missing required system property: " + name); + } + return value; + } + + private static void requirePngName(String name) { + if (!name.matches("[a-z0-9-]+\\.png")) { + throw new IllegalArgumentException("Screenshot name must be a simple kebab-case PNG filename: " + name); + } + } + + private static void requireDocker() { + if (!"true".equalsIgnoreCase(System.getenv("E2E_IN_DOCKER"))) { + throw new IllegalStateException("Screenshot baselines may only be generated in Docker."); + } + } +} diff --git a/4-observers-and-route-parameters/src/test/resources/screenshots/edit-customer-route.png b/4-observers-and-route-parameters/src/test/resources/screenshots/edit-customer-route.png new file mode 100644 index 0000000..a68ed85 Binary files /dev/null and b/4-observers-and-route-parameters/src/test/resources/screenshots/edit-customer-route.png differ diff --git a/5-validating-and-binding-data/README.md b/5-validating-and-binding-data/README.md index 6b97b67..19d53c1 100644 --- a/5-validating-and-binding-data/README.md +++ b/5-validating-and-binding-data/README.md @@ -14,6 +14,7 @@ To run the app, ensure the following tools are installed: - Java 21 or higher - BBj 26.02 when running with local BBjServices - Maven +- Docker Desktop (or another Docker engine) for end-to-end tests - A Java IDE (e.g., IntelliJ IDEA, Eclipse, VSCode) - Web browser - Git (recommended) @@ -49,6 +50,24 @@ webforj-tutorial ``` 3. Open your browser and go to [http://localhost:8080](http://localhost:8080). +## End-to-End and Screenshot Tests + +With Docker running, execute this command from this step's directory: + +```sh +mvn verify +``` + +Maven launches the step's Java Playwright tests in the pinned Docker image. Only this step is built and tested. Tests are in `src/test/java/com/webforj/tutorial`, screenshot baselines in `src/test/resources/screenshots`, and reports, traces, and logs under `target`. + +To update this step's baselines after an intentional visual change: + +```sh +mvn verify -DupdateScreenshots=true +``` + +Both test execution and baseline generation happen in Docker. `mvn test` runs unit tests; the end-to-end tests run during `mvn verify`. + ## Project Highlights - **Spring Boot integration:** Autowire Spring beans directly into webforJ views and components. diff --git a/5-validating-and-binding-data/pom.xml b/5-validating-and-binding-data/pom.xml index 57a0535..342d94e 100644 --- a/5-validating-and-binding-data/pom.xml +++ b/5-validating-and-binding-data/pom.xml @@ -18,6 +18,13 @@ 21 21 UTF-8 + 1.50.0 + 5.11.4 + *IT + false + false + ${maven.test.skip} + ${skipTests} @@ -61,7 +68,12 @@ com.microsoft.playwright playwright - 1.49.0 + ${playwright.version} + test + + + org.junit.jupiter + junit-jupiter test @@ -83,22 +95,14 @@ org.springframework.boot spring-boot-maven-plugin - true + + + - maven-failsafe-plugin - - - - integration-test - verify - - - - - false - + org.springframework.boot + spring-boot-maven-plugin @@ -113,5 +117,99 @@ true + + + docker-e2e + + + !docker.e2e.container + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + + docker-e2e + verify + exec + + ${skipITs} + docker + + run + --rm + --init + --ipc=host + --workdir=/app + -e + CI=true + -e + E2E_IN_DOCKER=true + --mount=type=bind,source=${project.basedir},target=/app + -v + webforj-tutorial-e2e-maven-repo:/var/maven/.m2 + -e + MAVEN_CONFIG=/var/maven/.m2 + mcr.microsoft.com/playwright/java:v${playwright.version}-noble + mvn + -B + -ntp + -Dmaven.repo.local=/var/maven/.m2/repository + -Ddocker.e2e.container=true + -DupdateScreenshots=${updateScreenshots} + -Dit.test=${it.test} + clean + verify + + + + + + + + + + + container-e2e + + + docker.e2e.container + true + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.2.5 + + + + integration-test + verify + + + + + true + false + false + + ${project.basedir} + ${project.build.directory}/${project.build.finalName}.jar + ${project.build.directory} + ${project.basedir}/src/test/resources/screenshots + ${project.build.directory}/visual-diffs + + + + + + diff --git a/5-validating-and-binding-data/src/main/resources/application.properties b/5-validating-and-binding-data/src/main/resources/application.properties index 18be8f3..695482f 100644 --- a/5-validating-and-binding-data/src/main/resources/application.properties +++ b/5-validating-and-binding-data/src/main/resources/application.properties @@ -1,7 +1,10 @@ -# Spring Boot configuration -spring.application.name=CustomerApplication +# webforJ configuration +webforj.entry=com.webforj.tutorial.Application +webforj.debug=true +webforj.devtools.craftforj.enabled=true +webforj.devtools.livereload.enabled=true -# Server configuration +# Hot reload configuration server.shutdown=immediate server.port=8080 @@ -15,15 +18,7 @@ webforj.entry = com.webforj.tutorial.Application # H2 Database configuration spring.datasource.url=jdbc:h2:mem:testdb -spring.datasource.driverClassName=org.h2.Driver -spring.datasource.username=sa -spring.datasource.password= # JPA configuration -spring.jpa.database-platform=org.hibernate.dialect.H2Dialect spring.jpa.hibernate.ddl-auto=update -# H2 Console (optional - for development) -spring.h2.console.enabled=true -spring.h2.console.path=/h2-console - diff --git a/5-validating-and-binding-data/src/test/java/com/webforj/tutorial/BaseTest.java b/5-validating-and-binding-data/src/test/java/com/webforj/tutorial/BaseTest.java new file mode 100644 index 0000000..f34110d --- /dev/null +++ b/5-validating-and-binding-data/src/test/java/com/webforj/tutorial/BaseTest.java @@ -0,0 +1,155 @@ +package com.webforj.tutorial; + +import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.microsoft.playwright.Browser; +import com.microsoft.playwright.BrowserContext; +import com.microsoft.playwright.BrowserType; +import com.microsoft.playwright.ConsoleMessage; +import com.microsoft.playwright.Locator; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.Playwright; +import com.microsoft.playwright.Request; +import com.microsoft.playwright.Tracing; +import com.microsoft.playwright.assertions.PlaywrightAssertions; +import com.microsoft.playwright.options.AriaRole; +import com.microsoft.playwright.options.ColorScheme; +import com.microsoft.playwright.options.WaitUntilState; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestInfo; +import org.junit.jupiter.api.TestInstance; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +abstract class BaseTest { + private Playwright playwright; + private Browser browser; + private BrowserContext context; + private TutorialApp application; + private final List browserErrors = new ArrayList<>(); + + protected Page page; + + @BeforeAll + void startBrowser() { + TutorialApp.requireDocker(); + playwright = Playwright.create(); + browser = playwright.chromium().launch(new BrowserType.LaunchOptions().setHeadless(true)); + PlaywrightAssertions.setDefaultAssertionTimeout(10_000); + } + + @BeforeEach + void startApplicationAndCreateBrowserContext() throws Exception { + browserErrors.clear(); + application = TutorialApp.start(); + context = browser.newContext(new Browser.NewContextOptions() + .setViewportSize(1440, 900) + .setDeviceScaleFactor(1) + .setColorScheme(ColorScheme.LIGHT) + .setLocale("en-US") + .setTimezoneId("UTC") + .setIgnoreHTTPSErrors(true)); + context.tracing().start(new Tracing.StartOptions() + .setScreenshots(true) + .setSnapshots(true) + .setSources(true)); + + page = context.newPage(); + page.onPageError(error -> browserErrors.add("pageerror: " + error)); + page.onConsoleMessage(this::recordConsoleError); + page.onRequestFailed(this::recordRequestFailure); + } + + @AfterEach + void closeBrowserContext(TestInfo testInfo) throws IOException { + List errorsBeforeClose = List.copyOf(browserErrors); + try { + if (context != null) { + Path traceDirectory = Path.of(System.getProperty("e2e.output.dir"), "playwright-traces"); + Files.createDirectories(traceDirectory); + String testName = (getClass().getSimpleName() + "-" + testInfo.getDisplayName()) + .replaceAll("[^a-zA-Z0-9.-]", "-"); + context.tracing().stop(new Tracing.StopOptions() + .setPath(traceDirectory.resolve(testName + ".zip"))); + context.close(); + context = null; + } + } finally { + if (application != null) { + application.close(); + application = null; + } + } + assertTrue(errorsBeforeClose.isEmpty(), + () -> "Browser runtime/network errors:\n" + String.join("\n", errorsBeforeClose)); + } + + @AfterAll + void stopBrowser() { + if (browser != null) { + browser.close(); + } + if (playwright != null) { + playwright.close(); + } + } + + protected void openApplication() { + openApplication("/"); + } + + protected void openApplication(String path) { + String normalizedPath = path.startsWith("/") ? path : "/" + path; + page.navigate(application.baseUrl() + normalizedPath, + new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED)); + } + + protected Locator customerTable() { + return page.locator("dwc-table"); + } + + protected Locator expectCustomerTable() { + Locator table = customerTable(); + assertThat(table).isVisible(); + assertThat(table).containsText("First Name"); + assertThat(table).containsText("Last Name"); + assertThat(table).containsText("Company"); + assertThat(table).containsText("Country"); + assertThat(table).containsText("Alice"); + assertThat(table).containsText("TechCorp"); + return table; + } + + protected Locator expectCustomerRow(String identifyingCellText) { + Locator identifyingCell = page.getByRole(AriaRole.CELL, + new Page.GetByRoleOptions().setName(identifyingCellText).setExact(true)); + Locator row = page.getByRole(AriaRole.ROW) + .filter(new Locator.FilterOptions().setHas(identifyingCell)); + assertThat(row).hasCount(1); + return row; + } + + private void recordConsoleError(ConsoleMessage message) { + if ("error".equals(message.type())) { + browserErrors.add("console: " + message.text()); + } + } + + private void recordRequestFailure(Request request) { + String failure = request.failure(); + boolean cancelledWebforjPoll = "net::ERR_ABORTED".equals(failure) + && request.url().contains("/webforjServlet/webapprmi"); + if (!cancelledWebforjPoll) { + browserErrors.add( + "requestfailed: " + request.method() + " " + request.url() + " (" + failure + ")"); + } + } +} diff --git a/5-validating-and-binding-data/src/test/java/com/webforj/tutorial/Step5IT.java b/5-validating-and-binding-data/src/test/java/com/webforj/tutorial/Step5IT.java new file mode 100644 index 0000000..2da4beb --- /dev/null +++ b/5-validating-and-binding-data/src/test/java/com/webforj/tutorial/Step5IT.java @@ -0,0 +1,46 @@ +package com.webforj.tutorial; + +import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; + +import com.microsoft.playwright.Page; +import com.microsoft.playwright.options.AriaRole; +import java.io.IOException; +import java.util.regex.Pattern; +import org.junit.jupiter.api.Test; + +class Step5IT extends BaseTest { + @Test + void bindsAndValidatesCustomerBeforeSubmission() throws IOException { + openApplication(); + expectCustomerTable(); + + page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Add Customer")).click(); + var submit = page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Submit")); + assertThat(submit).isEnabled(); + + page.getByLabel("First Name").fill("Grace"); + submit.click(); + assertThat(page).hasURL(Pattern.compile("/customer$")); + var lastNameError = page.getByText( + "Customer last name is required", new Page.GetByTextOptions().setExact(true)); + assertThat(lastNameError).isVisible(); + page.getByLabel("Last Name").fill("Hopper"); + page.getByLabel("Company").fill("Compiler Systems"); + assertThat(lastNameError).isHidden(); + assertThat(submit).isEnabled(); + VisualAssertions.assertScreenshot(page, "valid-customer-form.png"); + + submit.click(); + assertThat(page).hasURL(Pattern.compile("/$")); + assertThat(expectCustomerRow("Grace")).containsText("Compiler Systems"); + } + + @Test + void loadsExistingCustomerIntoBindingContext() { + openApplication("/customer/1"); + assertThat(page.getByLabel("First Name")).hasValue("Alice"); + assertThat(page.getByLabel("Last Name")).hasValue("Smith"); + assertThat(page.getByRole(AriaRole.BUTTON, + new Page.GetByRoleOptions().setName("Submit"))).isEnabled(); + } +} diff --git a/5-validating-and-binding-data/src/test/java/com/webforj/tutorial/TutorialApp.java b/5-validating-and-binding-data/src/test/java/com/webforj/tutorial/TutorialApp.java new file mode 100644 index 0000000..9d3ae51 --- /dev/null +++ b/5-validating-and-binding-data/src/test/java/com/webforj/tutorial/TutorialApp.java @@ -0,0 +1,159 @@ +package com.webforj.tutorial; + +import java.io.IOException; +import java.net.ServerSocket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.TimeUnit; + +final class TutorialApp implements AutoCloseable { + private static final Duration STARTUP_TIMEOUT = Duration.ofMinutes(3); + private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(2)) + .build(); + + private final Process process; + private final String baseUrl; + private final Path logFile; + + private TutorialApp(Process process, String baseUrl, Path logFile) { + this.process = process; + this.baseUrl = baseUrl; + this.logFile = logFile; + } + + static TutorialApp start() throws Exception { + requireDocker(); + + Path stepPath = Path.of(System.getProperty("e2e.app.dir")).toAbsolutePath().normalize(); + Path jar = Path.of(System.getProperty("e2e.app.jar")).toAbsolutePath().normalize(); + if (!Files.isRegularFile(jar)) { + throw new IllegalStateException("Application JAR does not exist: " + jar + + ". Run the complete Maven verify lifecycle to package the application first."); + } + + int port = findFreePort(); + String baseUrl = "http://127.0.0.1:" + port; + Path logFile = Path.of(System.getProperty("e2e.output.dir"), "e2e-artifacts", "server.log"); + Files.createDirectories(logFile.getParent()); + + String javaExecutable = Path.of(System.getProperty("java.home"), "bin", "java").toString(); + ProcessBuilder processBuilder = new ProcessBuilder( + javaExecutable, + "-jar", + jar.toString(), + "--server.port=" + port, + "--server.address=127.0.0.1", + "--spring.jpa.hibernate.ddl-auto=create-drop", + "--webforj.devtools.browser.open=false", + "--webforj.devtools.livereload.enabled=false", + "--webforj.devtools.livereload.static-resources-enabled=false", + "--webforj.devtools.craftforj.enabled=false", + "--webforj.debug=false"); + processBuilder.directory(stepPath.toFile()); + processBuilder.redirectErrorStream(true); + processBuilder.redirectOutput(logFile.toFile()); + + Process process = processBuilder.start(); + TutorialApp app = new TutorialApp(process, baseUrl, logFile); + try { + app.waitUntilReady(); + return app; + } catch (Exception exception) { + app.close(); + throw exception; + } + } + + String baseUrl() { + return baseUrl; + } + + static void requireDocker() { + if (!"true".equalsIgnoreCase(System.getenv("E2E_IN_DOCKER"))) { + throw new IllegalStateException( + "E2E tests may only run in Docker. Run `mvn verify` from this tutorial step's directory."); + } + } + + private static int findFreePort() throws IOException { + for (int preferredPort : List.of(8080, 8090)) { + try (ServerSocket ignored = new ServerSocket(preferredPort)) { + return preferredPort; + } catch (IOException ignored) { + // Try the next preferred port. + } + } + + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } + + private void waitUntilReady() throws Exception { + long deadline = System.nanoTime() + STARTUP_TIMEOUT.toNanos(); + HttpRequest request = HttpRequest.newBuilder(URI.create(baseUrl + "/")) + .timeout(Duration.ofSeconds(3)) + .GET() + .build(); + + while (System.nanoTime() < deadline) { + if (!process.isAlive()) { + throw new IllegalStateException( + "Application exited before becoming ready.\n" + tailLog()); + } + + try { + HttpResponse response = HTTP_CLIENT.send( + request, HttpResponse.BodyHandlers.discarding()); + if (response.statusCode() < 500) { + return; + } + } catch (IOException ignored) { + // The server is still starting. + } + + Thread.sleep(500); + } + + throw new IllegalStateException( + "Application did not become ready within " + STARTUP_TIMEOUT + ".\n" + tailLog()); + } + + private String tailLog() { + try { + List lines = Files.readAllLines(logFile, StandardCharsets.UTF_8); + int start = Math.max(0, lines.size() - 100); + return "Server log: " + logFile + "\n" + String.join("\n", lines.subList(start, lines.size())); + } catch (IOException exception) { + return "Could not read server log " + logFile + ": " + exception.getMessage(); + } + } + + @Override + public void close() { + if (!process.isAlive()) { + return; + } + + process.descendants().forEach(ProcessHandle::destroy); + process.destroy(); + try { + if (!process.waitFor(10, TimeUnit.SECONDS)) { + process.descendants().forEach(ProcessHandle::destroyForcibly); + process.destroyForcibly(); + process.waitFor(5, TimeUnit.SECONDS); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + process.destroyForcibly(); + } + } +} diff --git a/5-validating-and-binding-data/src/test/java/com/webforj/tutorial/VisualAssertions.java b/5-validating-and-binding-data/src/test/java/com/webforj/tutorial/VisualAssertions.java new file mode 100644 index 0000000..c42d6b9 --- /dev/null +++ b/5-validating-and-binding-data/src/test/java/com/webforj/tutorial/VisualAssertions.java @@ -0,0 +1,198 @@ +package com.webforj.tutorial; + +import static org.junit.jupiter.api.Assertions.fail; + +import com.microsoft.playwright.Page; +import com.microsoft.playwright.options.ScreenshotAnimations; +import com.microsoft.playwright.options.ScreenshotCaret; +import com.microsoft.playwright.options.ScreenshotScale; +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import javax.imageio.ImageIO; + +final class VisualAssertions { + private static final int DEFAULT_MAX_DIFFERENT_PIXELS = 500; + private static final int CHANNEL_THRESHOLD = 51; + private static final int DEFAULT_TIMEOUT_MS = 10_000; + private static final int POLL_INTERVAL_MS = 250; + + /** + * Every {@code dwc-icon} resolves its artwork over the network and injects an {@code } into + * its own shadow root, so an unresolved icon is invisible in a screenshot while the surrounding + * DOM already looks complete. Icons live inside nested shadow roots, hence the manual walk. + */ + private static final String ICONS_RENDERED = """ + () => { + const icons = []; + const walk = (root) => { + for (const element of root.querySelectorAll('*')) { + if (element.localName === 'dwc-icon') { + icons.push(element); + } + if (element.shadowRoot) { + walk(element.shadowRoot); + } + } + }; + walk(document); + return icons.every(icon => icon.shadowRoot && icon.shadowRoot.querySelector('svg')); + } + """; + + private VisualAssertions() { + } + + static void assertScreenshot(Page page, String name) throws IOException { + requirePngName(name); + awaitRenderedIcons(page); + + Path baselineDirectory = Path.of(requiredProperty("visual.baseline.dir")); + Path artifactDirectory = Path.of(requiredProperty("visual.artifact.dir")); + Path baseline = baselineDirectory.resolve(name); + byte[] screenshot = captureStable(page, name, artifactDirectory); + + if (Boolean.getBoolean("updateScreenshots")) { + requireDocker(); + Files.createDirectories(baselineDirectory); + Files.write(baseline, screenshot); + return; + } + + if (!Files.exists(baseline)) { + fail("Missing Docker screenshot baseline " + baseline + + ". Generate it with `mvn verify -DupdateScreenshots=true`."); + } + + BufferedImage expected = ImageIO.read(baseline.toFile()); + if (expected == null) { + fail("Could not decode screenshot baseline for " + name); + } + + BufferedImage actual = ImageIO.read(new ByteArrayInputStream(screenshot)); + if (actual == null) { + fail("Could not decode captured screenshot for " + name); + } + + if (expected.getWidth() != actual.getWidth() || expected.getHeight() != actual.getHeight()) { + writeFailureArtifacts(artifactDirectory, name, screenshot, null); + fail("Screenshot dimensions differ for " + name + ": expected " + + expected.getWidth() + "x" + expected.getHeight() + ", actual " + + actual.getWidth() + "x" + actual.getHeight()); + } + + BufferedImage diff = new BufferedImage( + actual.getWidth(), actual.getHeight(), BufferedImage.TYPE_INT_ARGB); + int differentPixels = comparePixels(expected, actual, diff); + int maximum = Integer.getInteger("visual.maxDiffPixels", DEFAULT_MAX_DIFFERENT_PIXELS); + if (differentPixels > maximum) { + writeFailureArtifacts(artifactDirectory, name, screenshot, diff); + fail("Screenshot mismatch for " + name + ": " + differentPixels + + " pixels differ (maximum " + maximum + "). See " + artifactDirectory); + } + } + + private static void awaitRenderedIcons(Page page) { + try { + page.waitForFunction(ICONS_RENDERED, null, + new Page.WaitForFunctionOptions().setTimeout(timeoutMs())); + } catch (RuntimeException e) { + fail("Icons did not finish rendering within " + timeoutMs() + + " ms; screenshots would capture missing icons. Icon artwork is fetched from" + + " cdn.jsdelivr.net, so check network access from the container.", e); + } + } + + private static byte[] capture(Page page) { + page.evaluate("() => document.fonts.ready"); + return page.screenshot(new Page.ScreenshotOptions() + .setAnimations(ScreenshotAnimations.DISABLED) + .setCaret(ScreenshotCaret.HIDE) + .setScale(ScreenshotScale.CSS)); + } + + private static byte[] captureStable( + Page page, String name, Path artifactDirectory) throws IOException { + long deadline = System.nanoTime() + timeoutMs() * 1_000_000L; + byte[] previous = capture(page); + while (System.nanoTime() < deadline) { + page.waitForTimeout(POLL_INTERVAL_MS); + byte[] current = capture(page); + if (Arrays.equals(previous, current)) { + return current; + } + previous = current; + } + + writeFailureArtifacts(artifactDirectory, name, previous, null); + return fail("Page did not produce two consecutive identical screenshots for " + name + + " within " + timeoutMs() + " ms. Refusing to compare or update an unstable image. See " + + artifactDirectory); + } + + private static int comparePixels(BufferedImage expected, BufferedImage actual, BufferedImage diff) { + int differentPixels = 0; + for (int y = 0; y < actual.getHeight(); y++) { + for (int x = 0; x < actual.getWidth(); x++) { + int expectedRgb = expected.getRGB(x, y); + int actualRgb = actual.getRGB(x, y); + if (isDifferent(expectedRgb, actualRgb)) { + differentPixels++; + diff.setRGB(x, y, Color.MAGENTA.getRGB()); + } else { + Color pixel = new Color(actualRgb, true); + int gray = (pixel.getRed() + pixel.getGreen() + pixel.getBlue()) / 3; + diff.setRGB(x, y, new Color(gray, gray, gray, 110).getRGB()); + } + } + } + return differentPixels; + } + + private static int timeoutMs() { + return Integer.getInteger("visual.timeoutMs", DEFAULT_TIMEOUT_MS); + } + + private static boolean isDifferent(int expectedRgb, int actualRgb) { + Color expected = new Color(expectedRgb, true); + Color actual = new Color(actualRgb, true); + return Math.abs(expected.getRed() - actual.getRed()) > CHANNEL_THRESHOLD + || Math.abs(expected.getGreen() - actual.getGreen()) > CHANNEL_THRESHOLD + || Math.abs(expected.getBlue() - actual.getBlue()) > CHANNEL_THRESHOLD + || Math.abs(expected.getAlpha() - actual.getAlpha()) > CHANNEL_THRESHOLD; + } + + private static void writeFailureArtifacts( + Path artifactDirectory, String name, byte[] screenshot, BufferedImage diff) throws IOException { + Files.createDirectories(artifactDirectory); + String stem = name.substring(0, name.length() - ".png".length()); + Files.write(artifactDirectory.resolve(stem + "-actual.png"), screenshot); + if (diff != null) { + ImageIO.write(diff, "png", artifactDirectory.resolve(stem + "-diff.png").toFile()); + } + } + + private static String requiredProperty(String name) { + String value = System.getProperty(name); + if (value == null || value.isBlank()) { + throw new IllegalStateException("Missing required system property: " + name); + } + return value; + } + + private static void requirePngName(String name) { + if (!name.matches("[a-z0-9-]+\\.png")) { + throw new IllegalArgumentException("Screenshot name must be a simple kebab-case PNG filename: " + name); + } + } + + private static void requireDocker() { + if (!"true".equalsIgnoreCase(System.getenv("E2E_IN_DOCKER"))) { + throw new IllegalStateException("Screenshot baselines may only be generated in Docker."); + } + } +} diff --git a/5-validating-and-binding-data/src/test/resources/screenshots/valid-customer-form.png b/5-validating-and-binding-data/src/test/resources/screenshots/valid-customer-form.png new file mode 100644 index 0000000..d402c85 Binary files /dev/null and b/5-validating-and-binding-data/src/test/resources/screenshots/valid-customer-form.png differ diff --git a/6-integrating-an-app-layout/README.md b/6-integrating-an-app-layout/README.md index 6b97b67..19d53c1 100644 --- a/6-integrating-an-app-layout/README.md +++ b/6-integrating-an-app-layout/README.md @@ -14,6 +14,7 @@ To run the app, ensure the following tools are installed: - Java 21 or higher - BBj 26.02 when running with local BBjServices - Maven +- Docker Desktop (or another Docker engine) for end-to-end tests - A Java IDE (e.g., IntelliJ IDEA, Eclipse, VSCode) - Web browser - Git (recommended) @@ -49,6 +50,24 @@ webforj-tutorial ``` 3. Open your browser and go to [http://localhost:8080](http://localhost:8080). +## End-to-End and Screenshot Tests + +With Docker running, execute this command from this step's directory: + +```sh +mvn verify +``` + +Maven launches the step's Java Playwright tests in the pinned Docker image. Only this step is built and tested. Tests are in `src/test/java/com/webforj/tutorial`, screenshot baselines in `src/test/resources/screenshots`, and reports, traces, and logs under `target`. + +To update this step's baselines after an intentional visual change: + +```sh +mvn verify -DupdateScreenshots=true +``` + +Both test execution and baseline generation happen in Docker. `mvn test` runs unit tests; the end-to-end tests run during `mvn verify`. + ## Project Highlights - **Spring Boot integration:** Autowire Spring beans directly into webforJ views and components. diff --git a/6-integrating-an-app-layout/pom.xml b/6-integrating-an-app-layout/pom.xml index 1152168..6272d72 100644 --- a/6-integrating-an-app-layout/pom.xml +++ b/6-integrating-an-app-layout/pom.xml @@ -18,6 +18,13 @@ 21 21 UTF-8 + 1.50.0 + 5.11.4 + *IT + false + false + ${maven.test.skip} + ${skipTests} @@ -61,7 +68,12 @@ com.microsoft.playwright playwright - 1.49.0 + ${playwright.version} + test + + + org.junit.jupiter + junit-jupiter test @@ -83,22 +95,14 @@ org.springframework.boot spring-boot-maven-plugin - true + + + - maven-failsafe-plugin - - - - integration-test - verify - - - - - false - + org.springframework.boot + spring-boot-maven-plugin @@ -113,5 +117,99 @@ true + + + docker-e2e + + + !docker.e2e.container + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + + docker-e2e + verify + exec + + ${skipITs} + docker + + run + --rm + --init + --ipc=host + --workdir=/app + -e + CI=true + -e + E2E_IN_DOCKER=true + --mount=type=bind,source=${project.basedir},target=/app + -v + webforj-tutorial-e2e-maven-repo:/var/maven/.m2 + -e + MAVEN_CONFIG=/var/maven/.m2 + mcr.microsoft.com/playwright/java:v${playwright.version}-noble + mvn + -B + -ntp + -Dmaven.repo.local=/var/maven/.m2/repository + -Ddocker.e2e.container=true + -DupdateScreenshots=${updateScreenshots} + -Dit.test=${it.test} + clean + verify + + + + + + + + + + + container-e2e + + + docker.e2e.container + true + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.2.5 + + + + integration-test + verify + + + + + true + false + false + + ${project.basedir} + ${project.build.directory}/${project.build.finalName}.jar + ${project.build.directory} + ${project.basedir}/src/test/resources/screenshots + ${project.build.directory}/visual-diffs + + + + + + diff --git a/6-integrating-an-app-layout/src/main/resources/application.properties b/6-integrating-an-app-layout/src/main/resources/application.properties index 18be8f3..695482f 100644 --- a/6-integrating-an-app-layout/src/main/resources/application.properties +++ b/6-integrating-an-app-layout/src/main/resources/application.properties @@ -1,7 +1,10 @@ -# Spring Boot configuration -spring.application.name=CustomerApplication +# webforJ configuration +webforj.entry=com.webforj.tutorial.Application +webforj.debug=true +webforj.devtools.craftforj.enabled=true +webforj.devtools.livereload.enabled=true -# Server configuration +# Hot reload configuration server.shutdown=immediate server.port=8080 @@ -15,15 +18,7 @@ webforj.entry = com.webforj.tutorial.Application # H2 Database configuration spring.datasource.url=jdbc:h2:mem:testdb -spring.datasource.driverClassName=org.h2.Driver -spring.datasource.username=sa -spring.datasource.password= # JPA configuration -spring.jpa.database-platform=org.hibernate.dialect.H2Dialect spring.jpa.hibernate.ddl-auto=update -# H2 Console (optional - for development) -spring.h2.console.enabled=true -spring.h2.console.path=/h2-console - diff --git a/6-integrating-an-app-layout/src/test/java/com/webforj/tutorial/BaseTest.java b/6-integrating-an-app-layout/src/test/java/com/webforj/tutorial/BaseTest.java new file mode 100644 index 0000000..f34110d --- /dev/null +++ b/6-integrating-an-app-layout/src/test/java/com/webforj/tutorial/BaseTest.java @@ -0,0 +1,155 @@ +package com.webforj.tutorial; + +import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.microsoft.playwright.Browser; +import com.microsoft.playwright.BrowserContext; +import com.microsoft.playwright.BrowserType; +import com.microsoft.playwright.ConsoleMessage; +import com.microsoft.playwright.Locator; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.Playwright; +import com.microsoft.playwright.Request; +import com.microsoft.playwright.Tracing; +import com.microsoft.playwright.assertions.PlaywrightAssertions; +import com.microsoft.playwright.options.AriaRole; +import com.microsoft.playwright.options.ColorScheme; +import com.microsoft.playwright.options.WaitUntilState; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestInfo; +import org.junit.jupiter.api.TestInstance; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +abstract class BaseTest { + private Playwright playwright; + private Browser browser; + private BrowserContext context; + private TutorialApp application; + private final List browserErrors = new ArrayList<>(); + + protected Page page; + + @BeforeAll + void startBrowser() { + TutorialApp.requireDocker(); + playwright = Playwright.create(); + browser = playwright.chromium().launch(new BrowserType.LaunchOptions().setHeadless(true)); + PlaywrightAssertions.setDefaultAssertionTimeout(10_000); + } + + @BeforeEach + void startApplicationAndCreateBrowserContext() throws Exception { + browserErrors.clear(); + application = TutorialApp.start(); + context = browser.newContext(new Browser.NewContextOptions() + .setViewportSize(1440, 900) + .setDeviceScaleFactor(1) + .setColorScheme(ColorScheme.LIGHT) + .setLocale("en-US") + .setTimezoneId("UTC") + .setIgnoreHTTPSErrors(true)); + context.tracing().start(new Tracing.StartOptions() + .setScreenshots(true) + .setSnapshots(true) + .setSources(true)); + + page = context.newPage(); + page.onPageError(error -> browserErrors.add("pageerror: " + error)); + page.onConsoleMessage(this::recordConsoleError); + page.onRequestFailed(this::recordRequestFailure); + } + + @AfterEach + void closeBrowserContext(TestInfo testInfo) throws IOException { + List errorsBeforeClose = List.copyOf(browserErrors); + try { + if (context != null) { + Path traceDirectory = Path.of(System.getProperty("e2e.output.dir"), "playwright-traces"); + Files.createDirectories(traceDirectory); + String testName = (getClass().getSimpleName() + "-" + testInfo.getDisplayName()) + .replaceAll("[^a-zA-Z0-9.-]", "-"); + context.tracing().stop(new Tracing.StopOptions() + .setPath(traceDirectory.resolve(testName + ".zip"))); + context.close(); + context = null; + } + } finally { + if (application != null) { + application.close(); + application = null; + } + } + assertTrue(errorsBeforeClose.isEmpty(), + () -> "Browser runtime/network errors:\n" + String.join("\n", errorsBeforeClose)); + } + + @AfterAll + void stopBrowser() { + if (browser != null) { + browser.close(); + } + if (playwright != null) { + playwright.close(); + } + } + + protected void openApplication() { + openApplication("/"); + } + + protected void openApplication(String path) { + String normalizedPath = path.startsWith("/") ? path : "/" + path; + page.navigate(application.baseUrl() + normalizedPath, + new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED)); + } + + protected Locator customerTable() { + return page.locator("dwc-table"); + } + + protected Locator expectCustomerTable() { + Locator table = customerTable(); + assertThat(table).isVisible(); + assertThat(table).containsText("First Name"); + assertThat(table).containsText("Last Name"); + assertThat(table).containsText("Company"); + assertThat(table).containsText("Country"); + assertThat(table).containsText("Alice"); + assertThat(table).containsText("TechCorp"); + return table; + } + + protected Locator expectCustomerRow(String identifyingCellText) { + Locator identifyingCell = page.getByRole(AriaRole.CELL, + new Page.GetByRoleOptions().setName(identifyingCellText).setExact(true)); + Locator row = page.getByRole(AriaRole.ROW) + .filter(new Locator.FilterOptions().setHas(identifyingCell)); + assertThat(row).hasCount(1); + return row; + } + + private void recordConsoleError(ConsoleMessage message) { + if ("error".equals(message.type())) { + browserErrors.add("console: " + message.text()); + } + } + + private void recordRequestFailure(Request request) { + String failure = request.failure(); + boolean cancelledWebforjPoll = "net::ERR_ABORTED".equals(failure) + && request.url().contains("/webforjServlet/webapprmi"); + if (!cancelledWebforjPoll) { + browserErrors.add( + "requestfailed: " + request.method() + " " + request.url() + " (" + failure + ")"); + } + } +} diff --git a/6-integrating-an-app-layout/src/test/java/com/webforj/tutorial/Step6IT.java b/6-integrating-an-app-layout/src/test/java/com/webforj/tutorial/Step6IT.java new file mode 100644 index 0000000..1a3bd18 --- /dev/null +++ b/6-integrating-an-app-layout/src/test/java/com/webforj/tutorial/Step6IT.java @@ -0,0 +1,60 @@ +package com.webforj.tutorial; + +import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; + +import com.microsoft.playwright.Locator; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.options.AriaRole; +import java.io.IOException; +import java.util.regex.Pattern; +import org.junit.jupiter.api.Test; + +class Step6IT extends BaseTest { + @Test + void providesApplicationLayoutNavigation() throws IOException { + openApplication(); + expectCustomerTable(); + assertThat(page.getByRole(AriaRole.HEADING, + new Page.GetByRoleOptions().setName("Customer Table").setLevel(1))).isVisible(); + VisualAssertions.assertScreenshot(page, "dashboard-layout.png"); + + page.getByText("About", new Page.GetByTextOptions().setExact(true)).click(); + assertThat(page).hasURL(Pattern.compile("/about$")); + assertThat(page.getByRole(AriaRole.HEADING, + new Page.GetByRoleOptions().setName("About").setLevel(1))).isVisible(); + assertThat(page.getByRole(AriaRole.HEADING, + new Page.GetByRoleOptions().setName("Customer Manager").setLevel(2)).last()).isVisible(); + + page.getByText("Dashboard", new Page.GetByTextOptions().setExact(true)).click(); + assertThat(page).hasURL(Pattern.compile("/$")); + assertThat(customerTable()).isVisible(); + + page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Add Customer")).click(); + assertThat(page).hasURL(Pattern.compile("/customer$")); + assertThat(page.getByRole(AriaRole.HEADING, + new Page.GetByRoleOptions().setName("Customer Form").setLevel(1))).isVisible(); + page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Cancel")).click(); + assertThat(page).hasURL(Pattern.compile("/$")); + } + + @Test + void keepsApplicationLayoutUsableAtNarrowViewport() throws IOException { + page.setViewportSize(390, 844); + openApplication(); + expectCustomerTable(); + + assertThat(page.getByRole(AriaRole.HEADING, + new Page.GetByRoleOptions().setName("Customer Table").setLevel(1))).isVisible(); + assertThat(page.getByRole(AriaRole.BUTTON, + new Page.GetByRoleOptions().setName("Add Customer"))).isVisible(); + + Locator dashboardLink = page.getByRole(AriaRole.LINK, + new Page.GetByRoleOptions().setName(Pattern.compile("Dashboard"))); + assertThat(dashboardLink).not().isInViewport(); + VisualAssertions.assertScreenshot(page, "dashboard-mobile.png"); + + page.getByRole(AriaRole.BUTTON, + new Page.GetByRoleOptions().setName(Pattern.compile("menu", Pattern.CASE_INSENSITIVE))).click(); + assertThat(dashboardLink).isInViewport(); + } +} diff --git a/6-integrating-an-app-layout/src/test/java/com/webforj/tutorial/TutorialApp.java b/6-integrating-an-app-layout/src/test/java/com/webforj/tutorial/TutorialApp.java new file mode 100644 index 0000000..9d3ae51 --- /dev/null +++ b/6-integrating-an-app-layout/src/test/java/com/webforj/tutorial/TutorialApp.java @@ -0,0 +1,159 @@ +package com.webforj.tutorial; + +import java.io.IOException; +import java.net.ServerSocket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.TimeUnit; + +final class TutorialApp implements AutoCloseable { + private static final Duration STARTUP_TIMEOUT = Duration.ofMinutes(3); + private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(2)) + .build(); + + private final Process process; + private final String baseUrl; + private final Path logFile; + + private TutorialApp(Process process, String baseUrl, Path logFile) { + this.process = process; + this.baseUrl = baseUrl; + this.logFile = logFile; + } + + static TutorialApp start() throws Exception { + requireDocker(); + + Path stepPath = Path.of(System.getProperty("e2e.app.dir")).toAbsolutePath().normalize(); + Path jar = Path.of(System.getProperty("e2e.app.jar")).toAbsolutePath().normalize(); + if (!Files.isRegularFile(jar)) { + throw new IllegalStateException("Application JAR does not exist: " + jar + + ". Run the complete Maven verify lifecycle to package the application first."); + } + + int port = findFreePort(); + String baseUrl = "http://127.0.0.1:" + port; + Path logFile = Path.of(System.getProperty("e2e.output.dir"), "e2e-artifacts", "server.log"); + Files.createDirectories(logFile.getParent()); + + String javaExecutable = Path.of(System.getProperty("java.home"), "bin", "java").toString(); + ProcessBuilder processBuilder = new ProcessBuilder( + javaExecutable, + "-jar", + jar.toString(), + "--server.port=" + port, + "--server.address=127.0.0.1", + "--spring.jpa.hibernate.ddl-auto=create-drop", + "--webforj.devtools.browser.open=false", + "--webforj.devtools.livereload.enabled=false", + "--webforj.devtools.livereload.static-resources-enabled=false", + "--webforj.devtools.craftforj.enabled=false", + "--webforj.debug=false"); + processBuilder.directory(stepPath.toFile()); + processBuilder.redirectErrorStream(true); + processBuilder.redirectOutput(logFile.toFile()); + + Process process = processBuilder.start(); + TutorialApp app = new TutorialApp(process, baseUrl, logFile); + try { + app.waitUntilReady(); + return app; + } catch (Exception exception) { + app.close(); + throw exception; + } + } + + String baseUrl() { + return baseUrl; + } + + static void requireDocker() { + if (!"true".equalsIgnoreCase(System.getenv("E2E_IN_DOCKER"))) { + throw new IllegalStateException( + "E2E tests may only run in Docker. Run `mvn verify` from this tutorial step's directory."); + } + } + + private static int findFreePort() throws IOException { + for (int preferredPort : List.of(8080, 8090)) { + try (ServerSocket ignored = new ServerSocket(preferredPort)) { + return preferredPort; + } catch (IOException ignored) { + // Try the next preferred port. + } + } + + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } + + private void waitUntilReady() throws Exception { + long deadline = System.nanoTime() + STARTUP_TIMEOUT.toNanos(); + HttpRequest request = HttpRequest.newBuilder(URI.create(baseUrl + "/")) + .timeout(Duration.ofSeconds(3)) + .GET() + .build(); + + while (System.nanoTime() < deadline) { + if (!process.isAlive()) { + throw new IllegalStateException( + "Application exited before becoming ready.\n" + tailLog()); + } + + try { + HttpResponse response = HTTP_CLIENT.send( + request, HttpResponse.BodyHandlers.discarding()); + if (response.statusCode() < 500) { + return; + } + } catch (IOException ignored) { + // The server is still starting. + } + + Thread.sleep(500); + } + + throw new IllegalStateException( + "Application did not become ready within " + STARTUP_TIMEOUT + ".\n" + tailLog()); + } + + private String tailLog() { + try { + List lines = Files.readAllLines(logFile, StandardCharsets.UTF_8); + int start = Math.max(0, lines.size() - 100); + return "Server log: " + logFile + "\n" + String.join("\n", lines.subList(start, lines.size())); + } catch (IOException exception) { + return "Could not read server log " + logFile + ": " + exception.getMessage(); + } + } + + @Override + public void close() { + if (!process.isAlive()) { + return; + } + + process.descendants().forEach(ProcessHandle::destroy); + process.destroy(); + try { + if (!process.waitFor(10, TimeUnit.SECONDS)) { + process.descendants().forEach(ProcessHandle::destroyForcibly); + process.destroyForcibly(); + process.waitFor(5, TimeUnit.SECONDS); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + process.destroyForcibly(); + } + } +} diff --git a/6-integrating-an-app-layout/src/test/java/com/webforj/tutorial/VisualAssertions.java b/6-integrating-an-app-layout/src/test/java/com/webforj/tutorial/VisualAssertions.java new file mode 100644 index 0000000..c42d6b9 --- /dev/null +++ b/6-integrating-an-app-layout/src/test/java/com/webforj/tutorial/VisualAssertions.java @@ -0,0 +1,198 @@ +package com.webforj.tutorial; + +import static org.junit.jupiter.api.Assertions.fail; + +import com.microsoft.playwright.Page; +import com.microsoft.playwright.options.ScreenshotAnimations; +import com.microsoft.playwright.options.ScreenshotCaret; +import com.microsoft.playwright.options.ScreenshotScale; +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import javax.imageio.ImageIO; + +final class VisualAssertions { + private static final int DEFAULT_MAX_DIFFERENT_PIXELS = 500; + private static final int CHANNEL_THRESHOLD = 51; + private static final int DEFAULT_TIMEOUT_MS = 10_000; + private static final int POLL_INTERVAL_MS = 250; + + /** + * Every {@code dwc-icon} resolves its artwork over the network and injects an {@code } into + * its own shadow root, so an unresolved icon is invisible in a screenshot while the surrounding + * DOM already looks complete. Icons live inside nested shadow roots, hence the manual walk. + */ + private static final String ICONS_RENDERED = """ + () => { + const icons = []; + const walk = (root) => { + for (const element of root.querySelectorAll('*')) { + if (element.localName === 'dwc-icon') { + icons.push(element); + } + if (element.shadowRoot) { + walk(element.shadowRoot); + } + } + }; + walk(document); + return icons.every(icon => icon.shadowRoot && icon.shadowRoot.querySelector('svg')); + } + """; + + private VisualAssertions() { + } + + static void assertScreenshot(Page page, String name) throws IOException { + requirePngName(name); + awaitRenderedIcons(page); + + Path baselineDirectory = Path.of(requiredProperty("visual.baseline.dir")); + Path artifactDirectory = Path.of(requiredProperty("visual.artifact.dir")); + Path baseline = baselineDirectory.resolve(name); + byte[] screenshot = captureStable(page, name, artifactDirectory); + + if (Boolean.getBoolean("updateScreenshots")) { + requireDocker(); + Files.createDirectories(baselineDirectory); + Files.write(baseline, screenshot); + return; + } + + if (!Files.exists(baseline)) { + fail("Missing Docker screenshot baseline " + baseline + + ". Generate it with `mvn verify -DupdateScreenshots=true`."); + } + + BufferedImage expected = ImageIO.read(baseline.toFile()); + if (expected == null) { + fail("Could not decode screenshot baseline for " + name); + } + + BufferedImage actual = ImageIO.read(new ByteArrayInputStream(screenshot)); + if (actual == null) { + fail("Could not decode captured screenshot for " + name); + } + + if (expected.getWidth() != actual.getWidth() || expected.getHeight() != actual.getHeight()) { + writeFailureArtifacts(artifactDirectory, name, screenshot, null); + fail("Screenshot dimensions differ for " + name + ": expected " + + expected.getWidth() + "x" + expected.getHeight() + ", actual " + + actual.getWidth() + "x" + actual.getHeight()); + } + + BufferedImage diff = new BufferedImage( + actual.getWidth(), actual.getHeight(), BufferedImage.TYPE_INT_ARGB); + int differentPixels = comparePixels(expected, actual, diff); + int maximum = Integer.getInteger("visual.maxDiffPixels", DEFAULT_MAX_DIFFERENT_PIXELS); + if (differentPixels > maximum) { + writeFailureArtifacts(artifactDirectory, name, screenshot, diff); + fail("Screenshot mismatch for " + name + ": " + differentPixels + + " pixels differ (maximum " + maximum + "). See " + artifactDirectory); + } + } + + private static void awaitRenderedIcons(Page page) { + try { + page.waitForFunction(ICONS_RENDERED, null, + new Page.WaitForFunctionOptions().setTimeout(timeoutMs())); + } catch (RuntimeException e) { + fail("Icons did not finish rendering within " + timeoutMs() + + " ms; screenshots would capture missing icons. Icon artwork is fetched from" + + " cdn.jsdelivr.net, so check network access from the container.", e); + } + } + + private static byte[] capture(Page page) { + page.evaluate("() => document.fonts.ready"); + return page.screenshot(new Page.ScreenshotOptions() + .setAnimations(ScreenshotAnimations.DISABLED) + .setCaret(ScreenshotCaret.HIDE) + .setScale(ScreenshotScale.CSS)); + } + + private static byte[] captureStable( + Page page, String name, Path artifactDirectory) throws IOException { + long deadline = System.nanoTime() + timeoutMs() * 1_000_000L; + byte[] previous = capture(page); + while (System.nanoTime() < deadline) { + page.waitForTimeout(POLL_INTERVAL_MS); + byte[] current = capture(page); + if (Arrays.equals(previous, current)) { + return current; + } + previous = current; + } + + writeFailureArtifacts(artifactDirectory, name, previous, null); + return fail("Page did not produce two consecutive identical screenshots for " + name + + " within " + timeoutMs() + " ms. Refusing to compare or update an unstable image. See " + + artifactDirectory); + } + + private static int comparePixels(BufferedImage expected, BufferedImage actual, BufferedImage diff) { + int differentPixels = 0; + for (int y = 0; y < actual.getHeight(); y++) { + for (int x = 0; x < actual.getWidth(); x++) { + int expectedRgb = expected.getRGB(x, y); + int actualRgb = actual.getRGB(x, y); + if (isDifferent(expectedRgb, actualRgb)) { + differentPixels++; + diff.setRGB(x, y, Color.MAGENTA.getRGB()); + } else { + Color pixel = new Color(actualRgb, true); + int gray = (pixel.getRed() + pixel.getGreen() + pixel.getBlue()) / 3; + diff.setRGB(x, y, new Color(gray, gray, gray, 110).getRGB()); + } + } + } + return differentPixels; + } + + private static int timeoutMs() { + return Integer.getInteger("visual.timeoutMs", DEFAULT_TIMEOUT_MS); + } + + private static boolean isDifferent(int expectedRgb, int actualRgb) { + Color expected = new Color(expectedRgb, true); + Color actual = new Color(actualRgb, true); + return Math.abs(expected.getRed() - actual.getRed()) > CHANNEL_THRESHOLD + || Math.abs(expected.getGreen() - actual.getGreen()) > CHANNEL_THRESHOLD + || Math.abs(expected.getBlue() - actual.getBlue()) > CHANNEL_THRESHOLD + || Math.abs(expected.getAlpha() - actual.getAlpha()) > CHANNEL_THRESHOLD; + } + + private static void writeFailureArtifacts( + Path artifactDirectory, String name, byte[] screenshot, BufferedImage diff) throws IOException { + Files.createDirectories(artifactDirectory); + String stem = name.substring(0, name.length() - ".png".length()); + Files.write(artifactDirectory.resolve(stem + "-actual.png"), screenshot); + if (diff != null) { + ImageIO.write(diff, "png", artifactDirectory.resolve(stem + "-diff.png").toFile()); + } + } + + private static String requiredProperty(String name) { + String value = System.getProperty(name); + if (value == null || value.isBlank()) { + throw new IllegalStateException("Missing required system property: " + name); + } + return value; + } + + private static void requirePngName(String name) { + if (!name.matches("[a-z0-9-]+\\.png")) { + throw new IllegalArgumentException("Screenshot name must be a simple kebab-case PNG filename: " + name); + } + } + + private static void requireDocker() { + if (!"true".equalsIgnoreCase(System.getenv("E2E_IN_DOCKER"))) { + throw new IllegalStateException("Screenshot baselines may only be generated in Docker."); + } + } +} diff --git a/6-integrating-an-app-layout/src/test/resources/screenshots/dashboard-layout.png b/6-integrating-an-app-layout/src/test/resources/screenshots/dashboard-layout.png new file mode 100644 index 0000000..029b429 Binary files /dev/null and b/6-integrating-an-app-layout/src/test/resources/screenshots/dashboard-layout.png differ diff --git a/6-integrating-an-app-layout/src/test/resources/screenshots/dashboard-mobile.png b/6-integrating-an-app-layout/src/test/resources/screenshots/dashboard-mobile.png new file mode 100644 index 0000000..cba2a4d Binary files /dev/null and b/6-integrating-an-app-layout/src/test/resources/screenshots/dashboard-mobile.png differ diff --git a/README.md b/README.md index 6b97b67..f5df2d7 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ To run the app, ensure the following tools are installed: - Java 21 or higher - BBj 26.02 when running with local BBjServices - Maven +- Docker Desktop (or another Docker engine) for end-to-end tests - A Java IDE (e.g., IntelliJ IDEA, Eclipse, VSCode) - Web browser - Git (recommended) @@ -49,6 +50,45 @@ webforj-tutorial ``` 3. Open your browser and go to [http://localhost:8080](http://localhost:8080). +## End-to-End and Screenshot Tests + +Each tutorial step owns its Java Playwright tests in `src/test/java/com/webforj/tutorial`, mirroring the application's Java package, and its screenshot baselines in `src/test/resources/screenshots`. Test helpers are included in each step so the step remains self-contained, with no dependency on a sibling project. + +With Docker running, enter the step you want to test and run the normal Maven verification lifecycle: + +```sh +cd 3-routing-and-composites +mvn verify +``` + +This builds and tests only that step. Maven launches the pinned official Playwright Java Docker image with the step directory mounted at `/app`. Inside the container, Maven performs a clean build and runs the Java Playwright tests and screenshot comparisons with Failsafe. No root POM or custom Docker image is needed. + +To select a test class or method within the current step: + +```sh +mvn verify "-Dit.test=Step3IT#createsCustomerAndReturnsToTable" +``` + +After an intentional visual change, regenerate this step's baselines in the same Docker environment: + +```sh +mvn verify -DupdateScreenshots=true +``` + +GitHub Actions runs `scripts/run-e2e.sh`, which enters each step and invokes `mvn -B -ntp verify`. It continues after a failed step and exits unsuccessfully if any step failed. You can run the same script from the repository root using Bash (Git Bash on Windows): + +```sh +bash scripts/run-e2e.sh +``` + +The workflow's optional `step` input selects one step; scheduled runs test all steps. + +In each step, test reports are written to `target/failsafe-reports`, failure images to `target/visual-diffs`, Playwright traces to `target/playwright-traces`, and application logs to `target/e2e-artifacts`. The screenshot comparator allows up to 500 pixels beyond its per-channel tolerance to differ. + +Each test starts a fresh application and browser context and stops them afterward. Applications prefer port 8080, fall back to 8090, and otherwise select a free container port. `mvn test` runs unit tests, while `mvn verify` also runs the E2E tests in Docker. Standard `-DskipITs`, `-DskipTests`, and `-Dmaven.test.skip=true` flags skip the Docker run when requested. + +The `container-e2e` profile binds Failsafe only for the inner Docker run, and the test setup rejects execution unless `E2E_IN_DOCKER=true` before launching Playwright. Baseline generation uses this same guard. Each step's `playwright.version` selects both the Java dependency and the Docker image tag. + ## Project Highlights - **Spring Boot integration:** Autowire Spring beans directly into webforJ views and components. diff --git a/scripts/run-e2e.sh b/scripts/run-e2e.sh new file mode 100644 index 0000000..5843593 --- /dev/null +++ b/scripts/run-e2e.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +requested_step="${1:-all}" + +if (( $# > 1 )) || [[ ! "$requested_step" =~ ^(all|[1-9][0-9]*)$ ]]; then + printf 'Usage: bash scripts/run-e2e.sh [all|step-number]\n' >&2 + exit 2 +fi + +shopt -s nullglob +step_poms=("$repo_root"/[0-9]*-*/pom.xml) +selected_steps=() +failed_steps=() + +for pom in "${step_poms[@]}"; do + step_dir="$(dirname -- "$pom")" + step="$(basename -- "$step_dir")" + if [[ "$requested_step" != all && "$step" != "$requested_step"-* ]]; then + continue + fi + + selected_steps+=("$step") + printf '\nRunning E2E tests in %s\n' "$step" + if (cd -- "$step_dir" && mvn -B -ntp verify); then + printf 'PASS: %s\n' "$step" + else + failed_steps+=("$step") + printf 'FAIL: %s\n' "$step" >&2 + fi +done + +if (( ${#selected_steps[@]} == 0 )); then + printf 'No tutorial step found for: %s\n' "$requested_step" >&2 + exit 2 +fi + +if (( ${#failed_steps[@]} > 0 )); then + printf '\nFailed tutorial steps:\n' >&2 + printf ' %s\n' "${failed_steps[@]}" >&2 + exit 1 +fi + +printf '\nAll %s selected tutorial steps passed.\n' "${#selected_steps[@]}"