Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions 1-creating-a-basic-app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
19 changes: 18 additions & 1 deletion 1-creating-a-basic-app/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
<maven.compiler.target>21</maven.compiler.target>
<maven.compiler.source>21</maven.compiler.source>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<playwright.version>1.50.0</playwright.version>
<updateScreenshots>false</updateScreenshots>
</properties>
<dependencyManagement>
<dependencies>
Expand Down Expand Up @@ -53,7 +55,12 @@
<dependency>
<groupId>com.microsoft.playwright</groupId>
<artifactId>playwright</artifactId>
<version>1.49.0</version>
<version>${playwright.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Expand All @@ -79,6 +86,7 @@
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<executions>
<execution>
Expand All @@ -89,7 +97,16 @@
</execution>
</executions>
<configuration>
<failIfNoTests>true</failIfNoTests>
<trimStackTrace>false</trimStackTrace>
<redirectTestOutputToFile>true</redirectTestOutputToFile>
<systemPropertyVariables>
<e2e.output.dir>${project.build.directory}</e2e.output.dir>
<visual.baseline.dir>${project.basedir}/src/test/resources/screenshots</visual.baseline.dir>
<visual.artifact.dir>${project.build.directory}/visual-diffs</visual.artifact.dir>
<playwright.version>${playwright.version}</playwright.version>
<updateScreenshots>${updateScreenshots}</updateScreenshots>
</systemPropertyVariables>
</configuration>
</plugin>
</plugins>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> 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("<loopback>"));
} 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<String> 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 + ")");
}
}
}
Original file line number Diff line number Diff line change
@@ -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<PlaywrightServer> {
private static final int PORT = 3000;

PlaywrightServer() throws IOException {
super(DockerImageName.parse("mcr.microsoft.com/playwright/java:v"
+ System.getProperty("playwright.version") + "-noble"));
withExposedPorts(PORT);
waitingFor(Wait.forListeningPort().withStartupTimeout(Duration.ofMinutes(2)));
withCopyFileToContainer(MountableFile.forHostPath(extractLinuxDriver()), "/opt/playwright");
withCommand("sh", "-c", "chmod +x /opt/playwright/node && exec "
+ "/opt/playwright/node /opt/playwright/package/cli.js "
+ "run-server --port 3000 --host 0.0.0.0");
}

String wsUrl() {
return "ws://" + getHost() + ":" + getMappedPort(PORT) + "/";
}

// The Java dependency bundles the matching Linux server, so the container needs no npm or Maven.
private static Path extractLinuxDriver() throws IOException {
String architecture = System.getProperty("os.arch").toLowerCase();
String platform = architecture.contains("aarch64") || architecture.contains("arm64")
? "linux-arm64" : "linux";
String prefix = "driver/" + platform + "/";
Path destination = Path.of(System.getProperty("e2e.output.dir"), "playwright-server-driver");
Path jar;
try {
jar = Path.of(DriverJar.class.getProtectionDomain().getCodeSource().getLocation().toURI());
} catch (Exception e) {
throw new IOException("Could not locate the Playwright driver bundle", e);
}

try (ZipFile bundle = new ZipFile(jar.toFile())) {
Enumeration<? extends ZipEntry> entries = bundle.entries();
while (entries.hasMoreElements()) {
ZipEntry entry = entries.nextElement();
if (entry.isDirectory() || !entry.getName().startsWith(prefix)) {
continue;
}
Path output = destination.resolve(entry.getName().substring(prefix.length())).normalize();
if (!output.startsWith(destination)) {
throw new IOException("Invalid Playwright driver entry: " + entry.getName());
}
Files.createDirectories(output.getParent());
try (var input = bundle.getInputStream(entry)) {
Files.copy(input, output, StandardCopyOption.REPLACE_EXISTING);
}
}
}
if (!Files.isRegularFile(destination.resolve("node"))
|| !Files.isRegularFile(destination.resolve("package/cli.js"))) {
throw new IOException("Playwright Linux server is missing from " + jar);
}
return destination;
}
}
Original file line number Diff line number Diff line change
@@ -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");
}
}
Loading
Loading