";
}
- String logs = payara.getLogs();
+ String logs = containerLogs();
String msg = String.format("GET all failed, status=%d, body=%s, logs:\n%s", response.getStatus(), respBody, logs);
assertEquals(Response.Status.OK.getStatusCode(), response.getStatus(), msg);
}
diff --git a/ecosystem/testcontainers-example/src/test/java/fish/payara/examples/service/PatronServiceIT.java b/ecosystem/testcontainers-example/src/test/java/fish/payara/examples/service/PatronServiceIT.java
index 0b67b1d..730fdd5 100644
--- a/ecosystem/testcontainers-example/src/test/java/fish/payara/examples/service/PatronServiceIT.java
+++ b/ecosystem/testcontainers-example/src/test/java/fish/payara/examples/service/PatronServiceIT.java
@@ -1,46 +1,25 @@
package fish.payara.examples.service;
import fish.payara.examples.domain.Patron;
-import fish.payara.examples.testcontainers.PayaraMicroContainer;
-import jakarta.ws.rs.client.*;
-import jakarta.ws.rs.core.*;
-import org.junit.jupiter.api.*;
-import org.testcontainers.junit.jupiter.Container;
-import org.testcontainers.junit.jupiter.Testcontainers;
-import org.testcontainers.utility.DockerImageName;
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.core.GenericType;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import org.junit.jupiter.api.MethodOrderer;
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestMethodOrder;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
-@Testcontainers
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
-class PatronServiceIT {
-
- private static final String PAYARA_MICRO_VERSION = "6.2025.10";
- private static final int EXPOSED_PORT = 8080;
-
- @Container
- private static final PayaraMicroContainer payara = new PayaraMicroContainer(
- DockerImageName.parse("payara/micro:" + PAYARA_MICRO_VERSION))
- .withExposedPorts(EXPOSED_PORT)
- .withDeploymentPath("target/testcontainers-example-1.0.0.war");
-
- private Client client;
- private WebTarget baseTarget;
-
- @BeforeEach
- void setUp() {
- client = ClientBuilder.newClient();
- String appUrl = payara.getApplicationUrl();
- String separator = appUrl.endsWith("/") ? "" : "/";
- String baseUri = appUrl + separator + "application/resources/patrons";
- baseTarget = client.target(baseUri);
- }
+class PatronServiceIT extends AbstractServiceIT {
- @AfterEach
- void tearDown() {
- client.close();
+ @Override
+ protected String resourcePath() {
+ return "resources/patrons";
}
@Test
diff --git a/ecosystem/testcontainers-example/src/test/java/fish/payara/examples/testcontainers/AbstractContainerIT.java b/ecosystem/testcontainers-example/src/test/java/fish/payara/examples/testcontainers/AbstractContainerIT.java
new file mode 100644
index 0000000..6b8731e
--- /dev/null
+++ b/ecosystem/testcontainers-example/src/test/java/fish/payara/examples/testcontainers/AbstractContainerIT.java
@@ -0,0 +1,63 @@
+package fish.payara.examples.testcontainers;
+
+/**
+ * Base class for every integration test that needs a running Payara Micro
+ * instance with the application deployed, whether it is exercised over REST
+ * (see the {@code *ServiceIT} classes) or through a browser with Playwright
+ * (see the {@code *UiIT} classes).
+ *
+ * This deliberately does not use {@code @Testcontainers}/{@code @Container}.
+ * Those JUnit 5 annotations manage the container's lifecycle per test class:
+ * a static container gets started in that class's {@code beforeAll} and, in
+ * some circumstances, stopped again in its {@code afterAll} once that class's
+ * tests are done - which is exactly wrong for a container meant to be shared
+ * by several test classes, since whichever class happens to run first ends up
+ * tearing the container down again before the next class's tests get a
+ * chance to run against it.
+ *
+ * Instead this follows Testcontainers' documented
+ *
+ * "singleton container" pattern: the container is started eagerly, exactly
+ * once, in a static initializer (guaranteed by the JVM's class-initialization
+ * semantics to run at most once no matter how many subclasses trigger it), and
+ * is never explicitly stopped - Testcontainers' own Ryuk reaper cleans it up
+ * when the JVM (the forked failsafe JVM running the whole IT suite) exits.
+ */
+public abstract class AbstractContainerIT {
+
+ /**
+ * Context path the WAR is deployed under inside the container. Payara
+ * Micro's autodeploy derives the context root from the deployed file's
+ * name, and {@link PayaraMicroContainer#withDeploymentPath} always copies
+ * the WAR in as {@code application.war}, so this is always "application" -
+ * it's not the project's artifact name.
+ */
+ protected static final String APPLICATION_CONTEXT = "application/";
+
+ protected static final PayaraMicroContainer payara;
+
+ static {
+ payara = new PayaraMicroContainer();
+ payara.start();
+ }
+
+ /** Base URL of the deployed application, always ending with a trailing slash. */
+ protected static String applicationUrl() {
+ String appUrl = payara.getApplicationUrl();
+ return appUrl.endsWith("/") ? appUrl : appUrl + "/";
+ }
+
+ /** Base URL of the deployed application's "application" context, ending with a trailing slash. */
+ protected static String applicationContextUrl() {
+ return applicationUrl() + APPLICATION_CONTEXT;
+ }
+
+ /** Container logs, safe to call even if the container failed to start. */
+ protected static String containerLogs() {
+ try {
+ return payara.getLogs();
+ } catch (Exception e) {
+ return "";
+ }
+ }
+}
diff --git a/ecosystem/testcontainers-example/src/test/java/fish/payara/examples/testcontainers/PayaraMicroContainer.java b/ecosystem/testcontainers-example/src/test/java/fish/payara/examples/testcontainers/PayaraMicroContainer.java
index b636f2b..7eb1943 100644
--- a/ecosystem/testcontainers-example/src/test/java/fish/payara/examples/testcontainers/PayaraMicroContainer.java
+++ b/ecosystem/testcontainers-example/src/test/java/fish/payara/examples/testcontainers/PayaraMicroContainer.java
@@ -5,27 +5,46 @@
import org.testcontainers.utility.DockerImageName;
import org.testcontainers.utility.MountableFile;
+/**
+ * A Testcontainers {@link GenericContainer} pre-configured to run Payara Micro
+ * with the application WAR deployed.
+ *
+ * The no-arg constructor is fully self-configuring: it reads the Payara
+ * Micro version and the path to the WAR file from the {@code payara.version}
+ * and {@code war.path} system properties. Those properties are populated from
+ * the Maven {@code payara.version} property and the build output directory by
+ * the failsafe plugin (see pom.xml), so the version only needs to be defined
+ * once for the whole build instead of being repeated in every IT test.
+ */
public class PayaraMicroContainer extends GenericContainer {
-
+
private static final int DEFAULT_PORT = 8080;
private static final String DEFAULT_CONTEXT_PATH = "/";
protected static final String CONTEXT = "ObservabilityTool";
-
+
+ /**
+ * Fully self-configured container: version and WAR path are resolved from
+ * the {@code payara.version} / {@code war.path} system properties set by Maven.
+ */
+ public PayaraMicroContainer() {
+ this(DockerImageName.parse("payara/micro:" + requiredProperty("payara.version")));
+ withDeploymentPath(requiredProperty("war.path"));
+ }
+
public PayaraMicroContainer(DockerImageName dockerImageName) {
super(dockerImageName);
withExposedPorts(DEFAULT_PORT);
waitingFor(Wait.forLogMessage(".*Payara Micro .* ready.*\\n", 1));
-
}
-
+
public PayaraMicroContainer withDeploymentPath(String warPath) {
withCopyFileToContainer(
- MountableFile.forHostPath(warPath),
+ MountableFile.forHostPath(warPath),
"/opt/payara/deployments/application.war"
);
return this;
}
-
+
public String getApplicationUrl() {
return String.format(
"http://%s:%d%s",
@@ -34,4 +53,14 @@ public String getApplicationUrl() {
DEFAULT_CONTEXT_PATH
);
}
+
+ private static String requiredProperty(String name) {
+ String value = System.getProperty(name);
+ if (value == null || value.isBlank()) {
+ throw new IllegalStateException(
+ "System property '" + name + "' is not set. It must be supplied by the Maven build "
+ + "(see the failsafe plugin's systemPropertyVariables in pom.xml).");
+ }
+ return value;
+ }
}
\ No newline at end of file
diff --git a/ecosystem/testcontainers-example/src/test/java/fish/payara/examples/ui/AbstractUiIT.java b/ecosystem/testcontainers-example/src/test/java/fish/payara/examples/ui/AbstractUiIT.java
new file mode 100644
index 0000000..a20aa87
--- /dev/null
+++ b/ecosystem/testcontainers-example/src/test/java/fish/payara/examples/ui/AbstractUiIT.java
@@ -0,0 +1,199 @@
+package fish.payara.examples.ui;
+
+import com.microsoft.playwright.Browser;
+import com.microsoft.playwright.BrowserContext;
+import com.microsoft.playwright.BrowserType;
+import com.microsoft.playwright.Dialog;
+import com.microsoft.playwright.Locator;
+import com.microsoft.playwright.Page;
+import com.microsoft.playwright.Playwright;
+import com.microsoft.playwright.options.AriaRole;
+import fish.payara.examples.testcontainers.AbstractContainerIT;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.jupiter.api.extension.TestWatcher;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Optional;
+import java.util.UUID;
+
+/**
+ * Base class for browser-driven UI tests exercising the JSF pages of the
+ * application through Playwright, running against the same Payara Micro
+ * Testcontainer shared with the REST IT tests (see {@link AbstractContainerIT}).
+ *
+ * One headless Chromium instance is launched per test class (JUnit 5 runs
+ * {@code @BeforeAll}/{@code @AfterAll} around each concrete subclass); each
+ * individual test then gets its own {@link BrowserContext}/{@link Page} so
+ * tests don't leak cookies or state into one another. Note: the Playwright
+ * {@code chromium} browser binary needs to be installed once, which the
+ * build's {@code generate-test-resources} phase does automatically (see the
+ * exec-maven-plugin execution in pom.xml).
+ *
+ * On failure, a screenshot, the page's HTML, its URL and the Payara
+ * container logs are dumped under {@code target/playwright-failures/} (see
+ * {@link FailureDiagnostics}) so a failing test is debuggable from the build
+ * output alone, without having to reproduce it interactively.
+ */
+@ExtendWith(AbstractUiIT.FailureDiagnostics.class)
+public abstract class AbstractUiIT extends AbstractContainerIT {
+
+ private static Playwright playwright;
+ private static Browser browser;
+
+ protected BrowserContext context;
+ protected Page page;
+
+ @BeforeAll
+ static void launchBrowser() {
+ playwright = Playwright.create();
+ browser = playwright.chromium().launch(new BrowserType.LaunchOptions().setHeadless(true));
+ }
+
+ @AfterAll
+ static void closeBrowser() {
+ if (browser != null) {
+ browser.close();
+ }
+ if (playwright != null) {
+ playwright.close();
+ }
+ }
+
+ @BeforeEach
+ void newPage() {
+ context = browser.newContext();
+ page = context.newPage();
+ // The "Delete" links use a JS confirm() dialog; always accept it so
+ // delete flows don't need to register their own handler.
+ page.onDialog(Dialog::accept);
+ }
+
+ /**
+ * Navigates to a JSF page relative to the deployed application's
+ * "application" context, e.g. {@code "book.xhtml"} navigates to
+ * {@code http://host:port/application/book.xhtml} - the JSF pages live in
+ * the same WAR as the REST resources, under the same context path (see
+ * {@link fish.payara.examples.testcontainers.AbstractContainerIT#APPLICATION_CONTEXT}).
+ */
+ protected Page navigateTo(String relativePath) {
+ page.navigate(applicationContextUrl() + relativePath);
+ return page;
+ }
+
+ /** Clicks the form's "Save" button (an {@code h:commandButton}, so it has no stable id). */
+ protected void clickSave() {
+ page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Save")).click();
+ }
+
+ /**
+ * Locates the {@code h:dataTable} row containing the given text (e.g. a
+ * title or name). Scoped to {@code table.table-striped} - every page's
+ * {@code h:dataTable} is rendered with that class (see e.g. book.xhtml),
+ * while the entry form above it is a separate {@code h:panelGrid}
+ * ("table-form" class) that also renders as an HTML {@code }. That
+ * distinction matters on loan.xhtml in particular: a {@code }'s
+ * full option list counts as "text" for Playwright's hasText matching, so
+ * an unscoped locator would match the librarian/patron/book dropdown's
+ * row in the form instead of the actual data row. Matches any
+ * {@code } rather than assuming a {@code } wrapper, since
+ * {@code h:dataTable} doesn't always render one.
+ */
+ protected Locator rowContaining(String text) {
+ return page.locator("table.table-striped tr", new Page.LocatorOptions().setHasText(text));
+ }
+
+ /**
+ * The container's Payara instance and its database live for the whole test
+ * run, so test data isn't reset between tests. Suffix human-readable values
+ * with this to keep rows unique and independent of what other tests wrote.
+ */
+ protected static String unique(String prefix) {
+ return prefix + "-" + UUID.randomUUID().toString().substring(0, 8);
+ }
+
+ /**
+ * On test failure, saves a screenshot, the current page's HTML/URL, and
+ * the Payara container logs to {@code target/playwright-failures/}, named
+ * after the failing test. Best-effort: any problem while writing the
+ * diagnostics is swallowed so it never masks the original failure.
+ *
+ * This also owns closing the {@link BrowserContext} for every outcome
+ * (pass, fail, abort, disabled) instead of an {@code @AfterEach} method,
+ * because JUnit 5 runs {@code @AfterEach} - and therefore would have
+ * closed the page - before invoking {@link TestWatcher} callbacks.
+ * With cleanup in {@code @AfterEach}, {@link #testFailed} would always
+ * see an already-closed page and silently skip the screenshot/HTML
+ * capture (which is exactly what happened before this class took over
+ * closing: only the container log, which doesn't need the page, was ever
+ * written).
+ */
+ static final class FailureDiagnostics implements TestWatcher {
+
+ @Override
+ public void testFailed(ExtensionContext context, Throwable cause) {
+ withTestInstance(context, test -> {
+ captureDiagnostics(context, test);
+ closeContext(test);
+ });
+ }
+
+ @Override
+ public void testSuccessful(ExtensionContext context) {
+ withTestInstance(context, this::closeContext);
+ }
+
+ @Override
+ public void testAborted(ExtensionContext context, Throwable cause) {
+ withTestInstance(context, this::closeContext);
+ }
+
+ @Override
+ public void testDisabled(ExtensionContext context, Optional reason) {
+ withTestInstance(context, this::closeContext);
+ }
+
+ private void withTestInstance(ExtensionContext context, java.util.function.Consumer action) {
+ Object testInstance = context.getRequiredTestInstance();
+ if (testInstance instanceof AbstractUiIT) {
+ action.accept((AbstractUiIT) testInstance);
+ }
+ }
+
+ private void captureDiagnostics(ExtensionContext context, AbstractUiIT test) {
+ String name = context.getRequiredTestClass().getSimpleName() + "-" + context.getRequiredTestMethod().getName();
+ try {
+ Path dir = Path.of("target", "playwright-failures");
+ Files.createDirectories(dir);
+ if (test.page != null && !test.page.isClosed()) {
+ test.page.screenshot(new Page.ScreenshotOptions().setPath(dir.resolve(name + ".png")).setFullPage(true));
+ writeString(dir.resolve(name + ".html"), test.page.content());
+ writeString(dir.resolve(name + ".url.txt"), test.page.url());
+ }
+ writeString(dir.resolve(name + ".container.log"), AbstractUiIT.containerLogs());
+ } catch (Exception diagnosticsFailure) {
+ // Best-effort: never let diagnostics collection hide the real test failure.
+ }
+ }
+
+ private void closeContext(AbstractUiIT test) {
+ if (test.context != null) {
+ try {
+ test.context.close();
+ } catch (Exception ignored) {
+ // Best-effort cleanup.
+ }
+ }
+ }
+
+ private static void writeString(Path path, String content) throws IOException {
+ Files.write(path, content.getBytes(StandardCharsets.UTF_8));
+ }
+ }
+}
diff --git a/ecosystem/testcontainers-example/src/test/java/fish/payara/examples/ui/BookUiIT.java b/ecosystem/testcontainers-example/src/test/java/fish/payara/examples/ui/BookUiIT.java
new file mode 100644
index 0000000..2f3b3a5
--- /dev/null
+++ b/ecosystem/testcontainers-example/src/test/java/fish/payara/examples/ui/BookUiIT.java
@@ -0,0 +1,68 @@
+package fish.payara.examples.ui;
+
+import com.microsoft.playwright.Locator;
+import com.microsoft.playwright.options.AriaRole;
+import org.junit.jupiter.api.Test;
+
+import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
+
+/**
+ * Exercises the "Book" JSF page (book.xhtml): create, edit and delete a book
+ * through the browser, driven by Playwright against the deployed application.
+ */
+class BookUiIT extends AbstractUiIT {
+
+ @Test
+ void createsABookAndShowsItInTheList() {
+ String title = unique("Playwright Book");
+
+ navigateTo("book.xhtml");
+ // Leave the Isbn field blank: the id is auto-generated on create, same
+ // as the REST API path (see BookBean#save / AbstractService#create).
+ page.getByLabel("Title:").fill(title);
+ page.getByLabel("Author:").fill("Jane Author");
+ page.getByLabel("Pages:").fill("123");
+ clickSave();
+
+ Locator row = rowContaining(title);
+ assertThat(row).isVisible();
+ assertThat(row).containsText("Jane Author");
+ assertThat(row).containsText("123");
+ }
+
+ @Test
+ void editsABook() {
+ String title = unique("Book To Edit");
+ String updatedTitle = unique("Updated Book");
+
+ navigateTo("book.xhtml");
+ page.getByLabel("Title:").fill(title);
+ page.getByLabel("Author:").fill("Original Author");
+ page.getByLabel("Pages:").fill("50");
+ clickSave();
+
+ rowContaining(title).getByRole(AriaRole.LINK, new Locator.GetByRoleOptions().setName("Edit")).click();
+ page.getByLabel("Title:").fill(updatedTitle);
+ clickSave();
+
+ assertThat(rowContaining(updatedTitle)).containsText("Original Author");
+ assertThat(rowContaining(title)).hasCount(0);
+ }
+
+ @Test
+ void deletesABook() {
+ String title = unique("Book To Delete");
+
+ navigateTo("book.xhtml");
+ page.getByLabel("Title:").fill(title);
+ page.getByLabel("Author:").fill("Delete Author");
+ page.getByLabel("Pages:").fill("10");
+ clickSave();
+
+ assertThat(rowContaining(title)).isVisible();
+
+ rowContaining(title).getByRole(AriaRole.LINK, new Locator.GetByRoleOptions().setName("Delete")).click();
+
+ assertThat(rowContaining(title)).hasCount(0);
+ }
+}
diff --git a/ecosystem/testcontainers-example/src/test/java/fish/payara/examples/ui/LibrarianUiIT.java b/ecosystem/testcontainers-example/src/test/java/fish/payara/examples/ui/LibrarianUiIT.java
new file mode 100644
index 0000000..06671a9
--- /dev/null
+++ b/ecosystem/testcontainers-example/src/test/java/fish/payara/examples/ui/LibrarianUiIT.java
@@ -0,0 +1,63 @@
+package fish.payara.examples.ui;
+
+import com.microsoft.playwright.Locator;
+import com.microsoft.playwright.options.AriaRole;
+import org.junit.jupiter.api.Test;
+
+import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
+
+/**
+ * Exercises the "Librarian" JSF page (librarian.xhtml): create, edit and
+ * delete a librarian through the browser, driven by Playwright against the
+ * deployed application.
+ */
+class LibrarianUiIT extends AbstractUiIT {
+
+ @Test
+ void createsALibrarianAndShowsItInTheList() {
+ String name = unique("Playwright Librarian");
+
+ navigateTo("librarian.xhtml");
+ // Leave the Librarian ID field blank: it's auto-generated on create,
+ // same as the REST API path (see LibrarianBean#save / AbstractService#create).
+ page.getByLabel("Name:").fill(name);
+ page.getByLabel("Department:").fill("Circulation");
+ clickSave();
+
+ Locator row = rowContaining(name);
+ assertThat(row).isVisible();
+ assertThat(row).containsText("Circulation");
+ }
+
+ @Test
+ void editsALibrarian() {
+ String name = unique("Librarian To Edit");
+
+ navigateTo("librarian.xhtml");
+ page.getByLabel("Name:").fill(name);
+ page.getByLabel("Department:").fill("Old Department");
+ clickSave();
+
+ rowContaining(name).getByRole(AriaRole.LINK, new Locator.GetByRoleOptions().setName("Edit")).click();
+ page.getByLabel("Department:").fill("New Department");
+ clickSave();
+
+ assertThat(rowContaining(name)).containsText("New Department");
+ }
+
+ @Test
+ void deletesALibrarian() {
+ String name = unique("Librarian To Delete");
+
+ navigateTo("librarian.xhtml");
+ page.getByLabel("Name:").fill(name);
+ page.getByLabel("Department:").fill("Doomed Department");
+ clickSave();
+
+ assertThat(rowContaining(name)).isVisible();
+
+ rowContaining(name).getByRole(AriaRole.LINK, new Locator.GetByRoleOptions().setName("Delete")).click();
+
+ assertThat(rowContaining(name)).hasCount(0);
+ }
+}
diff --git a/ecosystem/testcontainers-example/src/test/java/fish/payara/examples/ui/LoanUiIT.java b/ecosystem/testcontainers-example/src/test/java/fish/payara/examples/ui/LoanUiIT.java
new file mode 100644
index 0000000..4a7875b
--- /dev/null
+++ b/ecosystem/testcontainers-example/src/test/java/fish/payara/examples/ui/LoanUiIT.java
@@ -0,0 +1,103 @@
+package fish.payara.examples.ui;
+
+import com.microsoft.playwright.Locator;
+import com.microsoft.playwright.options.AriaRole;
+import com.microsoft.playwright.options.SelectOption;
+import org.junit.jupiter.api.Test;
+
+import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
+
+/**
+ * Exercises the "Loan" JSF page (loan.xhtml). Unlike the other entities, a
+ * loan is created by picking an existing librarian, patron and book from
+ * dropdowns, so this test first creates one of each through their own pages
+ * (reusing the same browser session) before creating, editing and deleting
+ * the loan itself.
+ */
+class LoanUiIT extends AbstractUiIT {
+
+ @Test
+ void createsALoanFromAnExistingLibrarianPatronAndBook() {
+ String librarianName = unique("Loan Librarian");
+ String patronName = unique("Loan Patron");
+ String bookTitle = unique("Loan Book");
+
+ createLibrarian(librarianName);
+ createPatron(patronName);
+ createBook(bookTitle);
+ fillAndSaveLoan(librarianName, patronName, bookTitle, "2026-07-14T09:00", "2026-07-28T09:00");
+
+ Locator row = rowContaining(librarianName);
+ assertThat(row).isVisible();
+ assertThat(row).containsText(patronName);
+ assertThat(row).containsText(bookTitle);
+ assertThat(row).containsText("2026-07-14T09:00");
+ }
+
+ @Test
+ void editsALoan() {
+ String librarianName = unique("Loan Librarian Edit");
+ String patronName = unique("Loan Patron Edit");
+ String bookTitle = unique("Loan Book Edit");
+
+ createLibrarian(librarianName);
+ createPatron(patronName);
+ createBook(bookTitle);
+ fillAndSaveLoan(librarianName, patronName, bookTitle, "2026-07-14T09:00", "2026-07-28T09:00");
+
+ rowContaining(librarianName).getByRole(AriaRole.LINK, new Locator.GetByRoleOptions().setName("Edit")).click();
+ page.getByLabel("Return Date:").fill("2026-08-04T09:00");
+ clickSave();
+
+ assertThat(rowContaining(librarianName)).containsText("2026-08-04T09:00");
+ }
+
+ @Test
+ void deletesALoan() {
+ String librarianName = unique("Loan Librarian Delete");
+ String patronName = unique("Loan Patron Delete");
+ String bookTitle = unique("Loan Book Delete");
+
+ createLibrarian(librarianName);
+ createPatron(patronName);
+ createBook(bookTitle);
+ fillAndSaveLoan(librarianName, patronName, bookTitle, "2026-07-14T09:00", "2026-07-28T09:00");
+
+ assertThat(rowContaining(librarianName)).isVisible();
+
+ rowContaining(librarianName).getByRole(AriaRole.LINK, new Locator.GetByRoleOptions().setName("Delete")).click();
+
+ assertThat(rowContaining(librarianName)).hasCount(0);
+ }
+
+ private void createLibrarian(String name) {
+ navigateTo("librarian.xhtml");
+ page.getByLabel("Name:").fill(name);
+ clickSave();
+ }
+
+ private void createPatron(String name) {
+ navigateTo("patron.xhtml");
+ page.getByLabel("Name:").fill(name);
+ clickSave();
+ }
+
+ private void createBook(String title) {
+ navigateTo("book.xhtml");
+ page.getByLabel("Title:").fill(title);
+ page.getByLabel("Author:").fill("Loan Test Author");
+ page.getByLabel("Pages:").fill("42");
+ clickSave();
+ }
+
+ private void fillAndSaveLoan(String librarianName, String patronName, String bookTitle,
+ String loanDate, String returnDate) {
+ navigateTo("loan.xhtml");
+ page.getByLabel("Loan Date:").fill(loanDate);
+ page.getByLabel("Return Date:").fill(returnDate);
+ page.getByLabel("Librarian:").selectOption(new SelectOption().setLabel(librarianName));
+ page.getByLabel("Patron:").selectOption(new SelectOption().setLabel(patronName));
+ page.getByLabel("Book:").selectOption(new SelectOption().setLabel(bookTitle));
+ clickSave();
+ }
+}
diff --git a/ecosystem/testcontainers-example/src/test/java/fish/payara/examples/ui/PatronUiIT.java b/ecosystem/testcontainers-example/src/test/java/fish/payara/examples/ui/PatronUiIT.java
new file mode 100644
index 0000000..eb0163f
--- /dev/null
+++ b/ecosystem/testcontainers-example/src/test/java/fish/payara/examples/ui/PatronUiIT.java
@@ -0,0 +1,67 @@
+package fish.payara.examples.ui;
+
+import com.microsoft.playwright.Locator;
+import com.microsoft.playwright.options.AriaRole;
+import org.junit.jupiter.api.Test;
+
+import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
+
+/**
+ * Exercises the "Patron" JSF page (patron.xhtml): create, edit and delete a
+ * patron through the browser, driven by Playwright against the deployed
+ * application.
+ */
+class PatronUiIT extends AbstractUiIT {
+
+ @Test
+ void createsAPatronAndShowsItInTheList() {
+ String name = unique("Playwright Patron");
+
+ navigateTo("patron.xhtml");
+ // Leave the Patron ID field blank: it's auto-generated on create, same
+ // as the REST API path (see PatronBean#save / AbstractService#create).
+ page.getByLabel("Name:").fill(name);
+ page.getByLabel("Address:").fill("1 Library Way");
+ page.getByLabel("Email:").fill("patron@example.com");
+ clickSave();
+
+ Locator row = rowContaining(name);
+ assertThat(row).isVisible();
+ assertThat(row).containsText("1 Library Way");
+ assertThat(row).containsText("patron@example.com");
+ }
+
+ @Test
+ void editsAPatron() {
+ String name = unique("Patron To Edit");
+
+ navigateTo("patron.xhtml");
+ page.getByLabel("Name:").fill(name);
+ page.getByLabel("Address:").fill("Old Address");
+ page.getByLabel("Email:").fill("old@example.com");
+ clickSave();
+
+ rowContaining(name).getByRole(AriaRole.LINK, new Locator.GetByRoleOptions().setName("Edit")).click();
+ page.getByLabel("Email:").fill("new@example.com");
+ clickSave();
+
+ assertThat(rowContaining(name)).containsText("new@example.com");
+ }
+
+ @Test
+ void deletesAPatron() {
+ String name = unique("Patron To Delete");
+
+ navigateTo("patron.xhtml");
+ page.getByLabel("Name:").fill(name);
+ page.getByLabel("Address:").fill("Somewhere");
+ page.getByLabel("Email:").fill("delete@example.com");
+ clickSave();
+
+ assertThat(rowContaining(name)).isVisible();
+
+ rowContaining(name).getByRole(AriaRole.LINK, new Locator.GetByRoleOptions().setName("Delete")).click();
+
+ assertThat(rowContaining(name)).hasCount(0);
+ }
+}