diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
new file mode 100644
index 0000000..55976cd
--- /dev/null
+++ b/.github/workflows/e2e.yml
@@ -0,0 +1,63 @@
+name: End-to-End Tests
+
+on:
+ pull_request:
+ 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'
+
+permissions:
+ contents: read
+
+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: Verify all tutorial steps
+ if: github.event_name != 'workflow_dispatch' || inputs.step == 'all'
+ run: mvn -B -ntp -fae verify
+
+ - name: Verify selected tutorial step
+ if: github.event_name == 'workflow_dispatch' && inputs.step != 'all'
+ run: mvn -B -ntp -pl :customer-app-step-${{ inputs.step }} verify
+
+ - name: Upload test artifacts
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: e2e-artifacts
+ path: |
+ */target/surefire-reports
+ */target/failsafe-reports
+ */target/visual-diffs
+ */target/playwright-traces
+ if-no-files-found: ignore
+ retention-days: 14
diff --git a/1-creating-a-basic-app/README.md b/1-creating-a-basic-app/README.md
index 6b97b67..b1a85db 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,25 @@ webforj-tutorial
```
3. Open your browser and go to [http://localhost:8080](http://localhost:8080).
+## End-to-End and Screenshot Tests
+
+With Docker running, run from this step's directory:
+
+```sh
+mvn verify
+mvn verify "-Dit.test=Step1IT#rendersBasicApplicationAndOpensInformationDialog"
+mvn verify -DupdateScreenshots=true
+mvn verify -DskipITs
+mvn verify -DskipTests
+mvn verify "-Dmaven.test.skip=true"
+```
+
+To select this step from the repository root:
+
+```sh
+mvn -B -ntp -pl :customer-app-step-1 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..8194465 100644
--- a/1-creating-a-basic-app/pom.xml
+++ b/1-creating-a-basic-app/pom.xml
@@ -18,6 +18,8 @@
21
21
UTF-8
+ 1.50.0
+ false
@@ -53,7 +55,12 @@
com.microsoft.playwright
playwright
- 1.49.0
+ ${playwright.version}
+ test
+
+
+ org.testcontainers
+ testcontainers
test
@@ -79,6 +86,7 @@
+ org.apache.maven.plugins
maven-failsafe-plugin
@@ -89,7 +97,16 @@
+ true
false
+ true
+
+ ${project.build.directory}
+ ${project.basedir}/src/test/resources/screenshots
+ ${project.build.directory}/visual-diffs
+ ${playwright.version}
+ ${updateScreenshots}
+
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..ac7f5fd
--- /dev/null
+++ b/1-creating-a-basic-app/src/test/java/com/webforj/tutorial/BaseTest.java
@@ -0,0 +1,136 @@
+package com.webforj.tutorial;
+
+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.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.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.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.server.LocalServerPort;
+import org.springframework.test.context.ActiveProfiles;
+
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+@ActiveProfiles("test")
+abstract class BaseTest {
+ private static PlaywrightServer server;
+ private static Playwright playwright;
+ private static Browser browser;
+
+ private BrowserContext context;
+ private final List browserErrors = new ArrayList<>();
+
+ @LocalServerPort
+ private int port;
+
+ protected Page page;
+
+ @BeforeAll
+ static void startBrowser() throws IOException {
+ server = new PlaywrightServer();
+ server.start();
+ try {
+ playwright = Playwright.create();
+ browser = playwright.chromium().connect(server.wsUrl(),
+ new BrowserType.ConnectOptions().setExposeNetwork(""));
+ } catch (RuntimeException exception) {
+ server.stop();
+ throw exception;
+ }
+ PlaywrightAssertions.setDefaultAssertionTimeout(10_000);
+ }
+
+ @BeforeEach
+ void createBrowserContext() {
+ browserErrors.clear();
+ 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);
+ 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;
+ }
+ assertTrue(errorsBeforeClose.isEmpty(),
+ () -> "Browser runtime/network errors:\n" + String.join("\n", errorsBeforeClose));
+ }
+
+ @AfterAll
+ static void stopBrowser() {
+ if (browser != null) {
+ browser.close();
+ }
+ if (playwright != null) {
+ playwright.close();
+ }
+ if (server != null) {
+ server.stop();
+ }
+ }
+
+ protected void openApplication() {
+ openApplication("/");
+ }
+
+ protected void openApplication(String path) {
+ String normalizedPath = path.startsWith("/") ? path : "/" + path;
+ page.navigate("http://127.0.0.1:" + port + normalizedPath,
+ new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED));
+ }
+
+ 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/PlaywrightServer.java b/1-creating-a-basic-app/src/test/java/com/webforj/tutorial/PlaywrightServer.java
new file mode 100644
index 0000000..e403f2b
--- /dev/null
+++ b/1-creating-a-basic-app/src/test/java/com/webforj/tutorial/PlaywrightServer.java
@@ -0,0 +1,72 @@
+package com.webforj.tutorial;
+
+import com.microsoft.playwright.impl.driver.jar.DriverJar;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.time.Duration;
+import java.util.Enumeration;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.wait.strategy.Wait;
+import org.testcontainers.utility.DockerImageName;
+import org.testcontainers.utility.MountableFile;
+
+final class PlaywrightServer extends GenericContainer {
+ private static final int PORT = 3000;
+
+ PlaywrightServer() throws IOException {
+ super(DockerImageName.parse("mcr.microsoft.com/playwright/java:v"
+ + System.getProperty("playwright.version") + "-noble"));
+ withExposedPorts(PORT);
+ waitingFor(Wait.forListeningPort().withStartupTimeout(Duration.ofMinutes(2)));
+ withCopyFileToContainer(MountableFile.forHostPath(extractLinuxDriver()), "/opt/playwright");
+ withCommand("sh", "-c", "chmod +x /opt/playwright/node && exec "
+ + "/opt/playwright/node /opt/playwright/package/cli.js "
+ + "run-server --port 3000 --host 0.0.0.0");
+ }
+
+ String wsUrl() {
+ return "ws://" + getHost() + ":" + getMappedPort(PORT) + "/";
+ }
+
+ // The Java dependency bundles the matching Linux server, so the container needs no npm or Maven.
+ private static Path extractLinuxDriver() throws IOException {
+ String architecture = System.getProperty("os.arch").toLowerCase();
+ String platform = architecture.contains("aarch64") || architecture.contains("arm64")
+ ? "linux-arm64" : "linux";
+ String prefix = "driver/" + platform + "/";
+ Path destination = Path.of(System.getProperty("e2e.output.dir"), "playwright-server-driver");
+ Path jar;
+ try {
+ jar = Path.of(DriverJar.class.getProtectionDomain().getCodeSource().getLocation().toURI());
+ } catch (Exception e) {
+ throw new IOException("Could not locate the Playwright driver bundle", e);
+ }
+
+ try (ZipFile bundle = new ZipFile(jar.toFile())) {
+ Enumeration extends ZipEntry> entries = bundle.entries();
+ while (entries.hasMoreElements()) {
+ ZipEntry entry = entries.nextElement();
+ if (entry.isDirectory() || !entry.getName().startsWith(prefix)) {
+ continue;
+ }
+ Path output = destination.resolve(entry.getName().substring(prefix.length())).normalize();
+ if (!output.startsWith(destination)) {
+ throw new IOException("Invalid Playwright driver entry: " + entry.getName());
+ }
+ Files.createDirectories(output.getParent());
+ try (var input = bundle.getInputStream(entry)) {
+ Files.copy(input, output, StandardCopyOption.REPLACE_EXISTING);
+ }
+ }
+ }
+ if (!Files.isRegularFile(destination.resolve("node"))
+ || !Files.isRegularFile(destination.resolve("package/cli.js"))) {
+ throw new IOException("Playwright Linux server is missing from " + jar);
+ }
+ return destination;
+ }
+}
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/VisualAssertions.java b/1-creating-a-basic-app/src/test/java/com/webforj/tutorial/VisualAssertions.java
new file mode 100644
index 0000000..2afedfa
--- /dev/null
+++ b/1-creating-a-basic-app/src/test/java/com/webforj/tutorial/VisualAssertions.java
@@ -0,0 +1,192 @@
+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