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