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 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 } 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")) { + Files.createDirectories(baselineDirectory); + Files.write(baseline, screenshot); + return; + } + + if (!Files.exists(baseline)) { + fail("Missing 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); + } + } + +} diff --git a/1-creating-a-basic-app/src/test/resources/application-test.properties b/1-creating-a-basic-app/src/test/resources/application-test.properties new file mode 100644 index 0000000..8d5e618 --- /dev/null +++ b/1-creating-a-basic-app/src/test/resources/application-test.properties @@ -0,0 +1,6 @@ +spring.devtools.livereload.enabled=false +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 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..9862d89 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,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=Step2IT#rendersSeededDataDialogAndSorting" +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-2 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..51ed732 100644 --- a/2-working-with-data/pom.xml +++ b/2-working-with-data/pom.xml @@ -18,6 +18,8 @@ 21 21 UTF-8 + 1.50.0 + false @@ -61,7 +63,12 @@ com.microsoft.playwright playwright - 1.49.0 + ${playwright.version} + test + + + org.testcontainers + testcontainers test @@ -87,6 +94,7 @@ + org.apache.maven.plugins maven-failsafe-plugin @@ -97,7 +105,16 @@ + true false + true + + ${project.build.directory} + ${project.basedir}/src/test/resources/screenshots + ${project.build.directory}/visual-diffs + ${playwright.version} + ${updateScreenshots} + diff --git a/2-working-with-data/src/main/resources/application.properties b/2-working-with-data/src/main/resources/application.properties index 872c0a9..e582faf 100644 --- a/2-working-with-data/src/main/resources/application.properties +++ b/2-working-with-data/src/main/resources/application.properties @@ -6,11 +6,11 @@ server.shutdown=immediate server.port=8080 # webforJ DevTools configuration +spring.devtools.livereload.enabled=false webforj.devtools.livereload.enabled=true webforj.devtools.livereload.static-resources-enabled=true webforj.devtools.browser.open=true webforj.debug=true -webforj.devtools.craftforj.enabled=true webforj.entry = com.webforj.tutorial.Application # H2 Database configuration 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..c98ba3f --- /dev/null +++ b/2-working-with-data/src/test/java/com/webforj/tutorial/BaseTest.java @@ -0,0 +1,164 @@ +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.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)); + } + + 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/PlaywrightServer.java b/2-working-with-data/src/test/java/com/webforj/tutorial/PlaywrightServer.java new file mode 100644 index 0000000..e403f2b --- /dev/null +++ b/2-working-with-data/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 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/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/VisualAssertions.java b/2-working-with-data/src/test/java/com/webforj/tutorial/VisualAssertions.java new file mode 100644 index 0000000..2afedfa --- /dev/null +++ b/2-working-with-data/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 } 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")) { + Files.createDirectories(baselineDirectory); + Files.write(baseline, screenshot); + return; + } + + if (!Files.exists(baseline)) { + fail("Missing 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); + } + } + +} diff --git a/2-working-with-data/src/test/resources/application-test.properties b/2-working-with-data/src/test/resources/application-test.properties new file mode 100644 index 0000000..734c1bf --- /dev/null +++ b/2-working-with-data/src/test/resources/application-test.properties @@ -0,0 +1,7 @@ +spring.devtools.livereload.enabled=false +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 +spring.jpa.hibernate.ddl-auto=create-drop 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..aadac99 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,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=Step3IT#createsCustomerAndReturnsToTable" +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-3 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..fa2a43b 100644 --- a/3-routing-and-composites/pom.xml +++ b/3-routing-and-composites/pom.xml @@ -18,6 +18,8 @@ 21 21 UTF-8 + 1.50.0 + false @@ -61,7 +63,12 @@ com.microsoft.playwright playwright - 1.49.0 + ${playwright.version} + test + + + org.testcontainers + testcontainers test @@ -87,6 +94,7 @@ + org.apache.maven.plugins maven-failsafe-plugin @@ -97,7 +105,16 @@ + true false + true + + ${project.build.directory} + ${project.basedir}/src/test/resources/screenshots + ${project.build.directory}/visual-diffs + ${playwright.version} + ${updateScreenshots} + diff --git a/3-routing-and-composites/src/main/resources/application.properties b/3-routing-and-composites/src/main/resources/application.properties index 18be8f3..5f29dbf 100644 --- a/3-routing-and-composites/src/main/resources/application.properties +++ b/3-routing-and-composites/src/main/resources/application.properties @@ -6,11 +6,11 @@ server.shutdown=immediate server.port=8080 # webforJ DevTools configuration +spring.devtools.livereload.enabled=false webforj.devtools.livereload.enabled=true webforj.devtools.livereload.static-resources-enabled=true webforj.devtools.browser.open=true webforj.debug=true -webforj.devtools.craftforj.enabled=true webforj.entry = com.webforj.tutorial.Application # H2 Database configuration 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..c98ba3f --- /dev/null +++ b/3-routing-and-composites/src/test/java/com/webforj/tutorial/BaseTest.java @@ -0,0 +1,164 @@ +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.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)); + } + + 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/PlaywrightServer.java b/3-routing-and-composites/src/test/java/com/webforj/tutorial/PlaywrightServer.java new file mode 100644 index 0000000..e403f2b --- /dev/null +++ b/3-routing-and-composites/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 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/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/VisualAssertions.java b/3-routing-and-composites/src/test/java/com/webforj/tutorial/VisualAssertions.java new file mode 100644 index 0000000..2afedfa --- /dev/null +++ b/3-routing-and-composites/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 } 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")) { + Files.createDirectories(baselineDirectory); + Files.write(baseline, screenshot); + return; + } + + if (!Files.exists(baseline)) { + fail("Missing 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); + } + } + +} diff --git a/3-routing-and-composites/src/test/resources/application-test.properties b/3-routing-and-composites/src/test/resources/application-test.properties new file mode 100644 index 0000000..734c1bf --- /dev/null +++ b/3-routing-and-composites/src/test/resources/application-test.properties @@ -0,0 +1,7 @@ +spring.devtools.livereload.enabled=false +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 +spring.jpa.hibernate.ddl-auto=create-drop 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..3c8d3c8 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,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=Step4IT#showsRoutedCustomerFormConsistently" +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-4 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..a851134 100644 --- a/4-observers-and-route-parameters/pom.xml +++ b/4-observers-and-route-parameters/pom.xml @@ -18,6 +18,8 @@ 21 21 UTF-8 + 1.50.0 + false @@ -61,7 +63,12 @@ com.microsoft.playwright playwright - 1.49.0 + ${playwright.version} + test + + + org.testcontainers + testcontainers test @@ -87,6 +94,7 @@ + org.apache.maven.plugins maven-failsafe-plugin @@ -97,7 +105,16 @@ + true false + true + + ${project.build.directory} + ${project.basedir}/src/test/resources/screenshots + ${project.build.directory}/visual-diffs + ${playwright.version} + ${updateScreenshots} + 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..5f29dbf 100644 --- a/4-observers-and-route-parameters/src/main/resources/application.properties +++ b/4-observers-and-route-parameters/src/main/resources/application.properties @@ -6,11 +6,11 @@ server.shutdown=immediate server.port=8080 # webforJ DevTools configuration +spring.devtools.livereload.enabled=false webforj.devtools.livereload.enabled=true webforj.devtools.livereload.static-resources-enabled=true webforj.devtools.browser.open=true webforj.debug=true -webforj.devtools.craftforj.enabled=true webforj.entry = com.webforj.tutorial.Application # H2 Database configuration 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..c98ba3f --- /dev/null +++ b/4-observers-and-route-parameters/src/test/java/com/webforj/tutorial/BaseTest.java @@ -0,0 +1,164 @@ +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.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)); + } + + 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/PlaywrightServer.java b/4-observers-and-route-parameters/src/test/java/com/webforj/tutorial/PlaywrightServer.java new file mode 100644 index 0000000..e403f2b --- /dev/null +++ b/4-observers-and-route-parameters/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 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/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/VisualAssertions.java b/4-observers-and-route-parameters/src/test/java/com/webforj/tutorial/VisualAssertions.java new file mode 100644 index 0000000..2afedfa --- /dev/null +++ b/4-observers-and-route-parameters/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 } 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")) { + Files.createDirectories(baselineDirectory); + Files.write(baseline, screenshot); + return; + } + + if (!Files.exists(baseline)) { + fail("Missing 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); + } + } + +} diff --git a/4-observers-and-route-parameters/src/test/resources/application-test.properties b/4-observers-and-route-parameters/src/test/resources/application-test.properties new file mode 100644 index 0000000..734c1bf --- /dev/null +++ b/4-observers-and-route-parameters/src/test/resources/application-test.properties @@ -0,0 +1,7 @@ +spring.devtools.livereload.enabled=false +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 +spring.jpa.hibernate.ddl-auto=create-drop 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..c828976 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,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=Step5IT#bindsAndValidatesCustomerBeforeSubmission" +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-5 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..05c90c5 100644 --- a/5-validating-and-binding-data/pom.xml +++ b/5-validating-and-binding-data/pom.xml @@ -18,6 +18,8 @@ 21 21 UTF-8 + 1.50.0 + false @@ -61,7 +63,12 @@ com.microsoft.playwright playwright - 1.49.0 + ${playwright.version} + test + + + org.testcontainers + testcontainers test @@ -87,6 +94,7 @@ + org.apache.maven.plugins maven-failsafe-plugin @@ -97,7 +105,16 @@ + true false + true + + ${project.build.directory} + ${project.basedir}/src/test/resources/screenshots + ${project.build.directory}/visual-diffs + ${playwright.version} + ${updateScreenshots} + 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..5f29dbf 100644 --- a/5-validating-and-binding-data/src/main/resources/application.properties +++ b/5-validating-and-binding-data/src/main/resources/application.properties @@ -6,11 +6,11 @@ server.shutdown=immediate server.port=8080 # webforJ DevTools configuration +spring.devtools.livereload.enabled=false webforj.devtools.livereload.enabled=true webforj.devtools.livereload.static-resources-enabled=true webforj.devtools.browser.open=true webforj.debug=true -webforj.devtools.craftforj.enabled=true webforj.entry = com.webforj.tutorial.Application # H2 Database configuration 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..c98ba3f --- /dev/null +++ b/5-validating-and-binding-data/src/test/java/com/webforj/tutorial/BaseTest.java @@ -0,0 +1,164 @@ +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.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)); + } + + 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/PlaywrightServer.java b/5-validating-and-binding-data/src/test/java/com/webforj/tutorial/PlaywrightServer.java new file mode 100644 index 0000000..e403f2b --- /dev/null +++ b/5-validating-and-binding-data/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 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/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/VisualAssertions.java b/5-validating-and-binding-data/src/test/java/com/webforj/tutorial/VisualAssertions.java new file mode 100644 index 0000000..2afedfa --- /dev/null +++ b/5-validating-and-binding-data/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 } 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")) { + Files.createDirectories(baselineDirectory); + Files.write(baseline, screenshot); + return; + } + + if (!Files.exists(baseline)) { + fail("Missing 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); + } + } + +} diff --git a/5-validating-and-binding-data/src/test/resources/application-test.properties b/5-validating-and-binding-data/src/test/resources/application-test.properties new file mode 100644 index 0000000..734c1bf --- /dev/null +++ b/5-validating-and-binding-data/src/test/resources/application-test.properties @@ -0,0 +1,7 @@ +spring.devtools.livereload.enabled=false +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 +spring.jpa.hibernate.ddl-auto=create-drop 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..bd894b0 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,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=Step6IT#providesApplicationLayoutNavigation" +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-6 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..3f4ab65 100644 --- a/6-integrating-an-app-layout/pom.xml +++ b/6-integrating-an-app-layout/pom.xml @@ -18,6 +18,8 @@ 21 21 UTF-8 + 1.50.0 + false @@ -61,7 +63,12 @@ com.microsoft.playwright playwright - 1.49.0 + ${playwright.version} + test + + + org.testcontainers + testcontainers test @@ -87,6 +94,7 @@ + org.apache.maven.plugins maven-failsafe-plugin @@ -97,7 +105,16 @@ + true false + true + + ${project.build.directory} + ${project.basedir}/src/test/resources/screenshots + ${project.build.directory}/visual-diffs + ${playwright.version} + ${updateScreenshots} + 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..5f29dbf 100644 --- a/6-integrating-an-app-layout/src/main/resources/application.properties +++ b/6-integrating-an-app-layout/src/main/resources/application.properties @@ -6,11 +6,11 @@ server.shutdown=immediate server.port=8080 # webforJ DevTools configuration +spring.devtools.livereload.enabled=false webforj.devtools.livereload.enabled=true webforj.devtools.livereload.static-resources-enabled=true webforj.devtools.browser.open=true webforj.debug=true -webforj.devtools.craftforj.enabled=true webforj.entry = com.webforj.tutorial.Application # H2 Database configuration 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..5cfdc8f --- /dev/null +++ b/6-integrating-an-app-layout/src/test/java/com/webforj/tutorial/BaseTest.java @@ -0,0 +1,154 @@ +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.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)); + } + + 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; + } + + 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/PlaywrightServer.java b/6-integrating-an-app-layout/src/test/java/com/webforj/tutorial/PlaywrightServer.java new file mode 100644 index 0000000..e403f2b --- /dev/null +++ b/6-integrating-an-app-layout/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 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/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/VisualAssertions.java b/6-integrating-an-app-layout/src/test/java/com/webforj/tutorial/VisualAssertions.java new file mode 100644 index 0000000..2afedfa --- /dev/null +++ b/6-integrating-an-app-layout/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 } 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")) { + Files.createDirectories(baselineDirectory); + Files.write(baseline, screenshot); + return; + } + + if (!Files.exists(baseline)) { + fail("Missing 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); + } + } + +} diff --git a/6-integrating-an-app-layout/src/test/resources/application-test.properties b/6-integrating-an-app-layout/src/test/resources/application-test.properties new file mode 100644 index 0000000..734c1bf --- /dev/null +++ b/6-integrating-an-app-layout/src/test/resources/application-test.properties @@ -0,0 +1,7 @@ +spring.devtools.livereload.enabled=false +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 +spring.jpa.hibernate.ddl-auto=create-drop 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..90d9086 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) @@ -27,6 +28,7 @@ webforj-tutorial │ .gitignore │ LICENSE │ README.md +│ pom.xml │ tree.txt │ ├───1-creating-a-basic-app @@ -49,6 +51,26 @@ webforj-tutorial ``` 3. Open your browser and go to [http://localhost:8080](http://localhost:8080). +## End-to-End and Screenshot Tests + +Docker must be running. From the repository root: + +```sh +mvn -B -ntp -fae verify +mvn -B -ntp -pl :customer-app-step-3 verify +``` + +From `3-routing-and-composites` (or use the matching class in another step): + +```sh +mvn verify +mvn verify "-Dit.test=Step3IT#createsCustomerAndReturnsToTable" +mvn verify -DupdateScreenshots=true +mvn verify -DskipITs +mvn verify -DskipTests +mvn verify "-Dmaven.test.skip=true" +``` + ## Project Highlights - **Spring Boot integration:** Autowire Spring beans directly into webforJ views and components. diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..c64c7a4 --- /dev/null +++ b/pom.xml @@ -0,0 +1,18 @@ + + + 4.0.0 + com.webforj.tutorial + customer-app-tutorial + 1.0-SNAPSHOT + pom + + 1-creating-a-basic-app + 2-working-with-data + 3-routing-and-composites + 4-observers-and-route-parameters + 5-validating-and-binding-data + 6-integrating-an-app-layout + +