diff --git a/CLAUDE.md b/CLAUDE.md index c4a5429..d6b9cdc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,9 +9,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ./mvnw compile # compile only ./mvnw package # production build → target/*.jar java -jar target/*.jar # run production jar +./mvnw test # run all tests ``` -No test sources exist yet (`src/test` is empty) — `spring-boot-starter-test` and `browserless-test-spring` are on the classpath but unused. +**Tests** live in two flavours, both run by surefire (`*Test`): +- `views/*Test` — browserless (`browserless-test-spring`): the Vaadin UI is built server-side and asserted on the component tree, no browser. Fast (seconds). +- `e2e/*PlaywrightTest` — real headless Chromium (`com.microsoft.playwright:playwright`) against the app on a random port, via `e2e/PlaywrightTestBase`. Slow (~1 min). Playwright downloads its browsers into `~/.cache/ms-playwright` on first run; if that fails (offline runner, missing system libs) the tests are **skipped** via a JUnit assumption instead of failing the build. + +E2E assertions match the **English** captions: German is the unsuffixed fallback bundle (`translations.properties`), so `de` is not a provided locale and Vaadin serves `translations_en.properties` for a German browser. `PlaywrightTestBase` pins the context locale to `en-US` to make that explicit. **Java 25 toolchain required** (`pom.xml` sets `java.version=25`). If the default JDK on PATH is older (check `java -version`), point `JAVA_HOME` at a Java 25 install for the Maven build, e.g.: ```bash @@ -43,7 +48,7 @@ UI copy/data (chart labels, notifications) is in German. ## AI-Autofix – Regeln - Nur Issues mit Label `ai-ready` bearbeiten. - Branch-Namensschema: `ai/issue--`. -- Kein Fix ohne grüne Tests. Test-Befehl: ``. +- Kein Fix ohne grüne Tests. Test-Befehl: `./mvnw test`. - Bei Unklarheit / Scope > ~200 Zeilen Diff: NICHT fixen, stattdessen Issue kommentieren ("needs human") und Label `ai-blocked` setzen. - PR-Beschreibung MUSS `Closes #` enthalten. diff --git a/src/test/java/com/example/e2e/FormViewPlaywrightTest.java b/src/test/java/com/example/e2e/FormViewPlaywrightTest.java new file mode 100644 index 0000000..0f08821 --- /dev/null +++ b/src/test/java/com/example/e2e/FormViewPlaywrightTest.java @@ -0,0 +1,86 @@ +package com.example.e2e; + +import com.microsoft.playwright.Locator; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.options.AriaRole; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; + +/** + * End-to-end example covering {@link com.example.views.FormView}: rendering, + * binder validation and the two form buttons. Captions are English — see + * {@link PlaywrightTestBase#openPage()} for why. + */ +class FormViewPlaywrightTest extends PlaywrightTestBase { + + @BeforeEach + void openFormView() { + navigate("formular"); + } + + @Test + void form_rendersItsFieldsAndTheReadOnlyAge() { + assertThat(page.getByText("Registration")).isVisible(); + assertThat(field("First name")).isVisible(); + assertThat(field("Last name")).isVisible(); + assertThat(field("Email")).isVisible(); + // preset on the bean and bound read-only + assertThat(field("Age")).hasValue("37"); + assertThat(field("Age")).not().isEditable(); + } + + @Test + void save_withoutRequiredFields_showsErrorNotification() { + button("Save").click(); + + assertThat(notification()).containsText("Please check your entries"); + } + + @Test + void save_withInvalidEmail_showsErrorNotification() { + field("First name").fill("Max"); + field("Last name").fill("Mustermann"); + field("Email").fill("not-an-email"); + + button("Save").click(); + + assertThat(notification()).containsText("Please check your entries"); + } + + @Test + void save_withValidInput_showsSuccessNotification() { + field("First name").fill("Max"); + field("Last name").fill("Mustermann"); + field("Email").fill("max@example.com"); + + button("Save").click(); + + assertThat(notification()).containsText("Saved: Max Mustermann"); + } + + @Test + void reset_clearsTheEnteredValues() { + field("First name").fill("Max"); + field("Last name").fill("Mustermann"); + + button("Reset").click(); + + assertThat(field("First name")).hasValue(""); + assertThat(field("Last name")).hasValue(""); + } + + /** The input inside the Vaadin field carrying {@code label}. */ + private Locator field(String label) { + return page.getByLabel(label, new Page.GetByLabelOptions().setExact(true)); + } + + private Locator button(String caption) { + return page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName(caption).setExact(true)); + } + + private Locator notification() { + return page.locator("vaadin-notification-card"); + } +} diff --git a/src/test/java/com/example/e2e/PlaywrightTestBase.java b/src/test/java/com/example/e2e/PlaywrightTestBase.java new file mode 100644 index 0000000..7e09ef5 --- /dev/null +++ b/src/test/java/com/example/e2e/PlaywrightTestBase.java @@ -0,0 +1,93 @@ +package com.example.e2e; + +import com.example.Application; +import com.microsoft.playwright.Browser; +import com.microsoft.playwright.BrowserContext; +import com.microsoft.playwright.BrowserType; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.Playwright; +import com.microsoft.playwright.assertions.PlaywrightAssertions; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; + +import java.util.Locale; + +/** + * Base class for end-to-end tests that drive the running application in a real + * browser. The Spring Boot app is started on a random port, Playwright opens a + * headless Chromium against it, and every test gets a fresh browser context — + * so each test also gets a fresh Vaadin session. + * + *

Playwright downloads its browsers into {@code ~/.cache/ms-playwright} on + * first use. If that is not possible (offline runner, missing system libraries) + * the tests are skipped rather than failed, so the build stays green + * on runners that cannot host a browser. + */ +@SpringBootTest(classes = Application.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public abstract class PlaywrightTestBase { + + /** Generous: the first request in dev mode has to bootstrap the frontend. */ + private static final double TIMEOUT_MS = 60_000; + + private static Playwright playwright; + private static Browser browser; + + @LocalServerPort + private int port; + + private BrowserContext context; + protected Page page; + + @BeforeAll + static void launchBrowser() { + try { + playwright = Playwright.create(); + browser = playwright.chromium().launch(new BrowserType.LaunchOptions().setHeadless(true)); + } catch (Exception | UnsatisfiedLinkError e) { + closeBrowser(); + Assumptions.abort("no Playwright browser available: " + e.getMessage()); + } + PlaywrightAssertions.setDefaultAssertionTimeout(TIMEOUT_MS); + } + + @AfterAll + static void closeBrowser() { + if (browser != null) { + browser.close(); + browser = null; + } + if (playwright != null) { + playwright.close(); + playwright = null; + } + } + + @BeforeEach + void openPage() { + // Pin the locale so the assertions have one fixed set of captions to + // match. English, not German: the German bundle is the unsuffixed + // fallback (`translations.properties`), so `de` is not a provided + // locale and Vaadin serves `translations_en.properties` for it anyway. + context = browser.newContext(new Browser.NewContextOptions().setLocale(Locale.US.toLanguageTag())); + context.setDefaultTimeout(TIMEOUT_MS); + page = context.newPage(); + } + + @AfterEach + void closePage() { + if (context != null) { + context.close(); + context = null; + } + } + + /** Opens a Vaadin route, e.g. {@code navigate("formular")}. */ + protected void navigate(String route) { + page.navigate("http://localhost:" + port + "/" + route); + } +}