feat: add playwright-java skill for enterprise E2E test automation (#243)
This commit is contained in:
137
skills/playwright-java/BasePage.java
Normal file
137
skills/playwright-java/BasePage.java
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
package com.company.tests.base;
|
||||||
|
|
||||||
|
import com.company.tests.config.ConfigReader;
|
||||||
|
import com.microsoft.playwright.Locator;
|
||||||
|
import com.microsoft.playwright.Page;
|
||||||
|
import com.microsoft.playwright.options.AriaRole;
|
||||||
|
import com.microsoft.playwright.options.LoadState;
|
||||||
|
import com.microsoft.playwright.options.WaitForSelectorState;
|
||||||
|
import io.qameta.allure.Step;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BasePage – foundation for all Page Object classes.
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - Inject Page via constructor (never create Playwright here)
|
||||||
|
* - Provide common action helpers with Allure @Step reporting
|
||||||
|
* - Enforce strict locator strategy (role/label/testid first)
|
||||||
|
* - Declare getUrl() so pages can self-navigate
|
||||||
|
*/
|
||||||
|
public abstract class BasePage {
|
||||||
|
|
||||||
|
protected final Page page;
|
||||||
|
|
||||||
|
public BasePage(Page page) {
|
||||||
|
this.page = page;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Navigation ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Navigate to this page's URL relative to baseUrl */
|
||||||
|
@Step("Navigate to {this.getUrl()}")
|
||||||
|
public <T extends BasePage> T navigate() {
|
||||||
|
page.navigate(ConfigReader.getBaseUrl() + getUrl());
|
||||||
|
waitForPageLoad();
|
||||||
|
//noinspection unchecked
|
||||||
|
return (T) this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Each page declares its relative URL path, e.g. "/login" */
|
||||||
|
protected abstract String getUrl();
|
||||||
|
|
||||||
|
@Step("Wait for network idle")
|
||||||
|
protected void waitForPageLoad() {
|
||||||
|
page.waitForLoadState(LoadState.NETWORKIDLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Locator Shortcuts ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
protected Locator byRole(AriaRole role, String name) {
|
||||||
|
return page.getByRole(role, new Page.GetByRoleOptions().setName(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected Locator byLabel(String label) {
|
||||||
|
return page.getByLabel(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected Locator byTestId(String testId) {
|
||||||
|
return page.getByTestId(testId);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected Locator byText(String text) {
|
||||||
|
return page.getByText(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected Locator byPlaceholder(String placeholder) {
|
||||||
|
return page.getByPlaceholder(placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Action Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Step("Fill '{locatorDesc}' with value")
|
||||||
|
protected void fill(Locator locator, String value) {
|
||||||
|
locator.waitFor(new Locator.WaitForOptions()
|
||||||
|
.setState(WaitForSelectorState.VISIBLE)
|
||||||
|
.setTimeout(10_000));
|
||||||
|
locator.clear();
|
||||||
|
locator.fill(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Step("Click and wait for navigation")
|
||||||
|
protected void clickAndWaitForNav(Locator locator) {
|
||||||
|
page.waitForNavigation(() -> locator.click());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Step("Click '{locatorDesc}'")
|
||||||
|
protected void click(Locator locator) {
|
||||||
|
locator.waitFor(new Locator.WaitForOptions()
|
||||||
|
.setState(WaitForSelectorState.VISIBLE)
|
||||||
|
.setTimeout(10_000));
|
||||||
|
locator.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Step("Wait for element to be visible")
|
||||||
|
protected void waitForVisible(Locator locator, int timeoutMs) {
|
||||||
|
locator.waitFor(new Locator.WaitForOptions()
|
||||||
|
.setState(WaitForSelectorState.VISIBLE)
|
||||||
|
.setTimeout(timeoutMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Step("Wait for element to be hidden")
|
||||||
|
protected void waitForHidden(Locator locator) {
|
||||||
|
locator.waitFor(new Locator.WaitForOptions()
|
||||||
|
.setState(WaitForSelectorState.HIDDEN)
|
||||||
|
.setTimeout(15_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Step("Select option '{value}' from dropdown")
|
||||||
|
protected void selectOption(Locator locator, String value) {
|
||||||
|
locator.selectOption(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Step("Check checkbox")
|
||||||
|
protected void check(Locator locator) {
|
||||||
|
if (!locator.isChecked()) locator.check();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Step("Uncheck checkbox")
|
||||||
|
protected void uncheck(Locator locator) {
|
||||||
|
if (locator.isChecked()) locator.uncheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Scroll ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
protected void scrollIntoView(Locator locator) {
|
||||||
|
locator.scrollIntoViewIfNeeded();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Utility ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public String getCurrentUrl() {
|
||||||
|
return page.url();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPageTitle() {
|
||||||
|
return page.title();
|
||||||
|
}
|
||||||
|
}
|
||||||
126
skills/playwright-java/BaseTest.java
Normal file
126
skills/playwright-java/BaseTest.java
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
package com.company.tests.base;
|
||||||
|
|
||||||
|
import com.company.tests.config.ConfigReader;
|
||||||
|
import com.microsoft.playwright.*;
|
||||||
|
import com.microsoft.playwright.options.LoadState;
|
||||||
|
import io.qameta.allure.Allure;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.TestInfo;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thread-safe BaseTest for parallel Playwright execution.
|
||||||
|
* Handles Playwright/Browser/Context/Page lifecycle.
|
||||||
|
* Captures traces and screenshots on test completion.
|
||||||
|
*/
|
||||||
|
public abstract class BaseTest {
|
||||||
|
|
||||||
|
private static final ThreadLocal<Playwright> playwrightTL = new ThreadLocal<>();
|
||||||
|
private static final ThreadLocal<Browser> browserTL = new ThreadLocal<>();
|
||||||
|
private static final ThreadLocal<BrowserContext> contextTL = new ThreadLocal<>();
|
||||||
|
private static final ThreadLocal<Page> pageTL = new ThreadLocal<>();
|
||||||
|
|
||||||
|
/** Access the Page in test/page-object classes */
|
||||||
|
protected Page page() {
|
||||||
|
return pageTL.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUpPlaywright(TestInfo testInfo) throws Exception {
|
||||||
|
// Ensure trace/video output directories exist
|
||||||
|
Files.createDirectories(Paths.get("target/traces"));
|
||||||
|
Files.createDirectories(Paths.get("target/videos"));
|
||||||
|
|
||||||
|
Playwright playwright = Playwright.create();
|
||||||
|
playwrightTL.set(playwright);
|
||||||
|
|
||||||
|
BrowserType.LaunchOptions launchOptions = new BrowserType.LaunchOptions()
|
||||||
|
.setHeadless(ConfigReader.isHeadless())
|
||||||
|
.setSlowMo(Integer.parseInt(System.getProperty("slowMo", "0")));
|
||||||
|
|
||||||
|
Browser browser = resolveBrowser(playwright).launch(launchOptions);
|
||||||
|
browserTL.set(browser);
|
||||||
|
|
||||||
|
Browser.NewContextOptions contextOptions = new Browser.NewContextOptions()
|
||||||
|
.setViewportSize(1920, 1080)
|
||||||
|
.setLocale("en-US")
|
||||||
|
.setTimezoneId("Asia/Kolkata")
|
||||||
|
.setRecordVideoDir(Paths.get("target/videos/"));
|
||||||
|
|
||||||
|
// Load saved auth state if available
|
||||||
|
Path authState = Paths.get("target/auth/user-state.json");
|
||||||
|
if (Files.exists(authState)) {
|
||||||
|
contextOptions.setStorageStatePath(authState);
|
||||||
|
}
|
||||||
|
|
||||||
|
BrowserContext context = browser.newContext(contextOptions);
|
||||||
|
context.setDefaultTimeout(ConfigReader.getDefaultTimeout());
|
||||||
|
context.setDefaultNavigationTimeout(60_000);
|
||||||
|
|
||||||
|
// Start tracing
|
||||||
|
context.tracing().start(new Tracing.StartOptions()
|
||||||
|
.setScreenshots(true)
|
||||||
|
.setSnapshots(true)
|
||||||
|
.setSources(true));
|
||||||
|
|
||||||
|
contextTL.set(context);
|
||||||
|
|
||||||
|
Page page = context.newPage();
|
||||||
|
pageTL.set(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDownPlaywright(TestInfo testInfo) {
|
||||||
|
String safeName = testInfo.getDisplayName()
|
||||||
|
.replaceAll("[^a-zA-Z0-9._-]", "_")
|
||||||
|
.substring(0, Math.min(80, testInfo.getDisplayName().length()));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Capture screenshot for Allure
|
||||||
|
if (pageTL.get() != null) {
|
||||||
|
byte[] screenshot = pageTL.get().screenshot(
|
||||||
|
new Page.ScreenshotOptions().setFullPage(true));
|
||||||
|
Allure.addAttachment("Final Screenshot", "image/png",
|
||||||
|
new ByteArrayInputStream(screenshot), "png");
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Stop and save trace
|
||||||
|
if (contextTL.get() != null) {
|
||||||
|
contextTL.get().tracing().stop(new Tracing.StopOptions()
|
||||||
|
.setPath(Paths.get("target/traces/" + safeName + ".zip")));
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
|
||||||
|
// Close resources in order
|
||||||
|
closeQuietly(pageTL.get());
|
||||||
|
closeQuietly(contextTL.get());
|
||||||
|
closeQuietly(browserTL.get());
|
||||||
|
closeQuietly(playwrightTL.get());
|
||||||
|
|
||||||
|
pageTL.remove();
|
||||||
|
contextTL.remove();
|
||||||
|
browserTL.remove();
|
||||||
|
playwrightTL.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
private BrowserType resolveBrowser(Playwright playwright) {
|
||||||
|
return switch (System.getProperty("browser", "chromium").toLowerCase()) {
|
||||||
|
case "firefox" -> playwright.firefox();
|
||||||
|
case "webkit" -> playwright.webkit();
|
||||||
|
default -> playwright.chromium();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void closeQuietly(AutoCloseable closeable) {
|
||||||
|
if (closeable != null) {
|
||||||
|
try { closeable.close(); } catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
385
skills/playwright-java/SKILL.md
Normal file
385
skills/playwright-java/SKILL.md
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
---
|
||||||
|
name: playwright-java
|
||||||
|
description: "Scaffold, write, debug, and enhance enterprise-grade Playwright E2E tests in Java using Page Object Model, JUnit 5, Allure reporting, and parallel execution."
|
||||||
|
category: test-automation
|
||||||
|
risk: safe
|
||||||
|
source: community
|
||||||
|
date_added: "2025-03-08"
|
||||||
|
author: amalsam18
|
||||||
|
tags: [playwright, java, e2e-testing, junit5, page-object-model, allure, selenium-alternative]
|
||||||
|
tools: [claude, cursor,antigravity]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Playwright Java – Advanced Test Automation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This skill produces production-quality, enterprise-grade Playwright Java test code.
|
||||||
|
It enforces the Page Object Model (POM), strict locator strategies, thread-safe parallel
|
||||||
|
execution, and full Allure reporting integration. Targets Java 17+ and Playwright 1.44+.
|
||||||
|
|
||||||
|
Supporting reference files are available for deeper topics:
|
||||||
|
|
||||||
|
| Topic | File |
|
||||||
|
|-------|------|
|
||||||
|
| Maven POM, ConfigReader, Docker/CI setup | `references/config.md` |
|
||||||
|
| Component pattern, dropdowns, uploads, waits | `references/page-objects.md` |
|
||||||
|
| Full assertion API, soft assertions, visual testing | `references/assertions.md` |
|
||||||
|
| Fixtures, test data factory, auth state, retry | `references/fixtures.md` |
|
||||||
|
| Drop-in base class templates | `templates/BaseTest.java`, `templates/BasePage.java` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## When to Use This Skill
|
||||||
|
|
||||||
|
- Use when scaffolding a new Playwright Java project from scratch
|
||||||
|
- Use when writing Page Object classes or JUnit 5 test classes
|
||||||
|
- Use when the user asks about cross-browser testing, parallel execution, or Allure reports
|
||||||
|
- Use when fixing flaky tests or replacing `Thread.sleep()` with proper waits
|
||||||
|
- Use when setting up Playwright in CI/CD pipelines (GitHub Actions, Jenkins, Docker)
|
||||||
|
- Use when combining API calls and UI assertions in a single test (hybrid testing)
|
||||||
|
- Use when the user mentions "POM pattern", "BrowserContext", "Playwright fixtures", or "traces"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### Step 1: Decide the Approach
|
||||||
|
|
||||||
|
Use this matrix to pick the right pattern before writing any code:
|
||||||
|
|
||||||
|
| User Request | Approach |
|
||||||
|
|---|---|
|
||||||
|
| New project from scratch | Full scaffold — see `references/config.md` |
|
||||||
|
| Single feature test | POM page class + JUnit5 test class |
|
||||||
|
| API + UI hybrid | `APIRequestContext` alongside `Page` |
|
||||||
|
| Cross-browser | `@MethodSource` parameterized over browser names |
|
||||||
|
| Flaky test fix | Replace `sleep` with `waitFor` / `waitForResponse` |
|
||||||
|
| CI integration | `playwright install --with-deps` in pipeline |
|
||||||
|
| Parallel execution | `junit-platform.properties` + `ThreadLocal` |
|
||||||
|
| Rich reporting | Allure + Playwright trace + video recording |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 2: Scaffold the Project Structure
|
||||||
|
|
||||||
|
Always use this layout when creating a new project:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── test/
|
||||||
|
│ ├── java/com/company/tests/
|
||||||
|
│ │ ├── base/
|
||||||
|
│ │ │ ├── BaseTest.java ← templates/BaseTest.java
|
||||||
|
│ │ │ └── BasePage.java ← templates/BasePage.java
|
||||||
|
│ │ ├── pages/
|
||||||
|
│ │ │ └── LoginPage.java
|
||||||
|
│ │ ├── tests/
|
||||||
|
│ │ │ └── LoginTest.java
|
||||||
|
│ │ ├── utils/
|
||||||
|
│ │ │ ├── TestDataFactory.java
|
||||||
|
│ │ │ └── WaitUtils.java
|
||||||
|
│ │ └── config/
|
||||||
|
│ │ └── ConfigReader.java
|
||||||
|
│ └── resources/
|
||||||
|
│ ├── test.properties
|
||||||
|
│ ├── junit-platform.properties
|
||||||
|
│ └── testdata/users.json
|
||||||
|
pom.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3: Set Up Thread-Safe BaseTest
|
||||||
|
|
||||||
|
```java
|
||||||
|
public class BaseTest {
|
||||||
|
protected static ThreadLocal<Playwright> playwrightTL = new ThreadLocal<>();
|
||||||
|
protected static ThreadLocal<Browser> browserTL = new ThreadLocal<>();
|
||||||
|
protected static ThreadLocal<BrowserContext> contextTL = new ThreadLocal<>();
|
||||||
|
protected static ThreadLocal<Page> pageTL = new ThreadLocal<>();
|
||||||
|
|
||||||
|
protected Page page() { return pageTL.get(); }
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
Playwright playwright = Playwright.create();
|
||||||
|
playwrightTL.set(playwright);
|
||||||
|
|
||||||
|
Browser browser = resolveBrowser(playwright).launch(
|
||||||
|
new BrowserType.LaunchOptions()
|
||||||
|
.setHeadless(ConfigReader.isHeadless()));
|
||||||
|
browserTL.set(browser);
|
||||||
|
|
||||||
|
BrowserContext context = browser.newContext(new Browser.NewContextOptions()
|
||||||
|
.setViewportSize(1920, 1080)
|
||||||
|
.setRecordVideoDir(Paths.get("target/videos/"))
|
||||||
|
.setLocale("en-US"));
|
||||||
|
context.tracing().start(new Tracing.StartOptions()
|
||||||
|
.setScreenshots(true).setSnapshots(true));
|
||||||
|
contextTL.set(context);
|
||||||
|
pageTL.set(context.newPage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown(TestInfo testInfo) {
|
||||||
|
String name = testInfo.getDisplayName().replaceAll("[^a-zA-Z0-9]", "_");
|
||||||
|
contextTL.get().tracing().stop(new Tracing.StopOptions()
|
||||||
|
.setPath(Paths.get("target/traces/" + name + ".zip")));
|
||||||
|
pageTL.get().close();
|
||||||
|
contextTL.get().close();
|
||||||
|
browserTL.get().close();
|
||||||
|
playwrightTL.get().close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private BrowserType resolveBrowser(Playwright pw) {
|
||||||
|
return switch (System.getProperty("browser", "chromium").toLowerCase()) {
|
||||||
|
case "firefox" -> pw.firefox();
|
||||||
|
case "webkit" -> pw.webkit();
|
||||||
|
default -> pw.chromium();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 4: Build Page Object Classes
|
||||||
|
|
||||||
|
```java
|
||||||
|
public class LoginPage extends BasePage {
|
||||||
|
|
||||||
|
// Declare ALL locators as fields — never inline in action methods
|
||||||
|
private final Locator emailInput;
|
||||||
|
private final Locator passwordInput;
|
||||||
|
private final Locator loginButton;
|
||||||
|
private final Locator errorMessage;
|
||||||
|
|
||||||
|
public LoginPage(Page page) {
|
||||||
|
super(page);
|
||||||
|
emailInput = page.getByLabel("Email address");
|
||||||
|
passwordInput = page.getByLabel("Password");
|
||||||
|
loginButton = page.getByRole(AriaRole.BUTTON,
|
||||||
|
new Page.GetByRoleOptions().setName("Sign in"));
|
||||||
|
errorMessage = page.getByTestId("login-error");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override protected String getUrl() { return "/login"; }
|
||||||
|
|
||||||
|
// Navigation methods return the next Page Object — enables fluent chaining
|
||||||
|
public DashboardPage loginAs(String email, String password) {
|
||||||
|
fill(emailInput, email);
|
||||||
|
fill(passwordInput, password);
|
||||||
|
clickAndWaitForNav(loginButton);
|
||||||
|
return new DashboardPage(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
public LoginPage loginExpectingError(String email, String password) {
|
||||||
|
fill(emailInput, email);
|
||||||
|
fill(passwordInput, password);
|
||||||
|
loginButton.click();
|
||||||
|
errorMessage.waitFor();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getErrorMessage() { return errorMessage.textContent(); }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 5: Write Tests with Allure Annotations
|
||||||
|
|
||||||
|
```java
|
||||||
|
@ExtendWith(AllureJunit5.class)
|
||||||
|
class LoginTest extends BaseTest {
|
||||||
|
|
||||||
|
private LoginPage loginPage;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void openLoginPage() {
|
||||||
|
loginPage = new LoginPage(page());
|
||||||
|
loginPage.navigate();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Severity(SeverityLevel.BLOCKER)
|
||||||
|
@DisplayName("Valid credentials redirect to dashboard")
|
||||||
|
void shouldLoginWithValidCredentials() {
|
||||||
|
User user = TestDataFactory.getDefaultUser();
|
||||||
|
DashboardPage dash = loginPage.loginAs(user.email(), user.password());
|
||||||
|
|
||||||
|
assertThat(page()).hasURL(Pattern.compile(".*/dashboard"));
|
||||||
|
assertThat(dash.getWelcomeBanner()).containsText("Welcome, " + user.firstName());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldShowErrorOnInvalidCredentials() {
|
||||||
|
loginPage.loginExpectingError("bad@test.com", "wrongpass");
|
||||||
|
|
||||||
|
SoftAssertions softly = new SoftAssertions();
|
||||||
|
softly.assertThat(loginPage.getErrorMessage()).contains("Invalid email or password");
|
||||||
|
softly.assertThat(page()).hasURL(Pattern.compile(".*/login"));
|
||||||
|
softly.assertAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@MethodSource("provideInvalidCredentials")
|
||||||
|
void shouldRejectInvalidCredentials(String email, String password, String expectedError) {
|
||||||
|
loginPage.loginExpectingError(email, password);
|
||||||
|
assertThat(loginPage.getErrorMessage()).containsText(expectedError);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Stream<Arguments> provideInvalidCredentials() {
|
||||||
|
return Stream.of(
|
||||||
|
Arguments.of("", "password123", "Email is required"),
|
||||||
|
Arguments.of("user@test.com", "", "Password is required"),
|
||||||
|
Arguments.of("notanemail", "pass", "Invalid email format")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Example 1: API + UI Hybrid Test
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
void shouldDisplayNewlyCreatedOrder() {
|
||||||
|
// Arrange via API — faster than navigating through UI
|
||||||
|
APIRequestContext api = page().context().request();
|
||||||
|
APIResponse response = api.post("/api/orders",
|
||||||
|
RequestOptions.create()
|
||||||
|
.setHeader("Authorization", "Bearer " + authToken)
|
||||||
|
.setData(Map.of("productId", "SKU-001", "quantity", 2)));
|
||||||
|
assertThat(response).isOK();
|
||||||
|
|
||||||
|
String orderId = new JsonParser().parse(response.text())
|
||||||
|
.getAsJsonObject().get("id").getAsString();
|
||||||
|
|
||||||
|
OrdersPage orders = new OrdersPage(page());
|
||||||
|
orders.navigate();
|
||||||
|
assertThat(orders.getOrderRowById(orderId)).isVisible();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 2: Network Mocking
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
void shouldHandleApiFailureGracefully() {
|
||||||
|
page().route("**/api/products", route -> route.fulfill(
|
||||||
|
new Route.FulfillOptions()
|
||||||
|
.setStatus(503)
|
||||||
|
.setBody("{\"error\":\"Service Unavailable\"}")
|
||||||
|
.setContentType("application/json")));
|
||||||
|
|
||||||
|
ProductsPage products = new ProductsPage(page());
|
||||||
|
products.navigate();
|
||||||
|
|
||||||
|
assertThat(products.getErrorBanner())
|
||||||
|
.hasText("We're having trouble loading products. Please try again.");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 3: Parallel Cross-Browser Test
|
||||||
|
|
||||||
|
```java
|
||||||
|
@ParameterizedTest
|
||||||
|
@MethodSource("browsers")
|
||||||
|
void shouldRenderCheckoutOnAllBrowsers(String browserName) {
|
||||||
|
System.setProperty("browser", browserName);
|
||||||
|
new CheckoutPage(page()).navigate();
|
||||||
|
assertThat(page().locator(".checkout-form")).isVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Stream<String> browsers() {
|
||||||
|
return Stream.of("chromium", "firefox", "webkit");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 4: Parallel Execution Config
|
||||||
|
|
||||||
|
```properties
|
||||||
|
# src/test/resources/junit-platform.properties
|
||||||
|
junit.jupiter.execution.parallel.enabled=true
|
||||||
|
junit.jupiter.execution.parallel.mode.default=concurrent
|
||||||
|
junit.jupiter.execution.parallel.config.strategy=fixed
|
||||||
|
junit.jupiter.execution.parallel.config.fixed.parallelism=4
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 5: GitHub Actions CI Pipeline
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
run: mvn exec:java -e -Dexec.mainClass=com.microsoft.playwright.CLI -Dexec.args="install --with-deps"
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: mvn test -Dbrowser=${{ matrix.browser }} -Dheadless=true
|
||||||
|
|
||||||
|
- name: Upload traces on failure
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
if: failure()
|
||||||
|
with:
|
||||||
|
name: playwright-traces
|
||||||
|
path: target/traces/
|
||||||
|
|
||||||
|
- name: Upload Allure results
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: allure-results
|
||||||
|
path: target/allure-results/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
- ✅ Use `ThreadLocal<Page>` for every parallel-safe test suite
|
||||||
|
- ✅ Declare all `Locator` fields at the top of the Page Object class
|
||||||
|
- ✅ Return the next Page Object from navigation methods (fluent chaining)
|
||||||
|
- ✅ Use `assertThat(locator)` — it auto-retries until timeout
|
||||||
|
- ✅ Use `getByRole`, `getByLabel`, `getByTestId` as first-choice locators
|
||||||
|
- ✅ Start tracing in `@BeforeEach` and stop with a file path in `@AfterEach`
|
||||||
|
- ✅ Use `SoftAssertions` when validating multiple fields on a single page
|
||||||
|
- ✅ Set up saved auth state (`storageState`) to skip login across test classes
|
||||||
|
- ❌ Never use `Thread.sleep()` — replace with `waitFor()` or `waitForResponse()`
|
||||||
|
- ❌ Never hardcode base URLs — always use `ConfigReader.getBaseUrl()`
|
||||||
|
- ❌ Never create a `Playwright` instance inside a Page Object
|
||||||
|
- ❌ Never use XPath for dynamic or frequently changing elements
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
- **Problem:** Tests fail randomly in parallel mode
|
||||||
|
**Solution:** Ensure every test creates its own `Playwright → Browser → BrowserContext → Page` chain via `ThreadLocal`. Never share a `Page` across threads.
|
||||||
|
|
||||||
|
- **Problem:** `assertThat(locator).isVisible()` times out even when the element appears
|
||||||
|
**Solution:** Increase timeout with `.setTimeout(10_000)` or raise `context.setDefaultTimeout()` in `BaseTest`.
|
||||||
|
|
||||||
|
- **Problem:** `Thread.sleep(2000)` was added but tests are still flaky
|
||||||
|
**Solution:** Replace with `page.waitForResponse("**/api/endpoint", () -> action())` or `assertThat(locator).hasText("Done")` which polls automatically.
|
||||||
|
|
||||||
|
- **Problem:** Playwright trace zip is empty or missing
|
||||||
|
**Solution:** Ensure `tracing().start()` is called before test actions and `tracing().stop()` is in `@AfterEach` — not `@AfterAll`.
|
||||||
|
|
||||||
|
- **Problem:** Allure report is blank or missing steps
|
||||||
|
**Solution:** Add the AspectJ agent to `maven-surefire-plugin` `<argLine>` in `pom.xml` — see `references/config.md` for the exact snippet.
|
||||||
|
|
||||||
|
- **Problem:** `storageState` auth file is stale and tests redirect to login
|
||||||
|
**Solution:** Re-run `AuthSetup` to regenerate `target/auth/user-state.json` before the suite, or add a `@BeforeAll` that conditionally refreshes it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Skills
|
||||||
|
|
||||||
|
- `@rest-assured-java` — Use for pure API test suites without any UI interaction
|
||||||
|
- `@selenium-java` — Legacy alternative; prefer Playwright for all new projects
|
||||||
|
- `@allure-reporting` — Deep-dive into Allure annotations, categories, and history trends
|
||||||
|
- `@testcontainers-java` — Use alongside this skill when tests need a live database or service
|
||||||
|
- `@github-actions-ci` — For building complete multi-browser matrix CI pipelines
|
||||||
167
skills/playwright-java/assertions.md
Normal file
167
skills/playwright-java/assertions.md
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
# Playwright Java – Assertions Reference
|
||||||
|
|
||||||
|
## Import Statement
|
||||||
|
|
||||||
|
```java
|
||||||
|
import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
|
||||||
|
import org.assertj.core.api.SoftAssertions;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Locator Assertions (Auto-Retry)
|
||||||
|
|
||||||
|
Playwright's `assertThat(locator)` polls automatically (up to `defaultTimeout`).
|
||||||
|
**Always prefer these over `locator.isVisible()` + `assertTrue`.**
|
||||||
|
|
||||||
|
```java
|
||||||
|
// Visibility
|
||||||
|
assertThat(locator).isVisible();
|
||||||
|
assertThat(locator).isHidden();
|
||||||
|
|
||||||
|
// Enabled / Disabled
|
||||||
|
assertThat(locator).isEnabled();
|
||||||
|
assertThat(locator).isDisabled();
|
||||||
|
|
||||||
|
// Text content (exact or partial)
|
||||||
|
assertThat(locator).hasText("Exact text");
|
||||||
|
assertThat(locator).containsText("partial");
|
||||||
|
assertThat(locator).hasText(Pattern.compile("Order #\\d+"));
|
||||||
|
|
||||||
|
// Multiple elements
|
||||||
|
assertThat(locator).hasCount(5);
|
||||||
|
assertThat(locator).hasText(new String[]{"Item A", "Item B", "Item C"});
|
||||||
|
|
||||||
|
// Attribute
|
||||||
|
assertThat(locator).hasAttribute("aria-expanded", "true");
|
||||||
|
assertThat(locator).hasAttribute("href", Pattern.compile(".*\\/dashboard"));
|
||||||
|
|
||||||
|
// CSS class
|
||||||
|
assertThat(locator).hasClass("active");
|
||||||
|
assertThat(locator).hasClass(Pattern.compile("btn-.*"));
|
||||||
|
|
||||||
|
// Input value
|
||||||
|
assertThat(locator).hasValue("expected input value");
|
||||||
|
assertThat(locator).hasValue(Pattern.compile("\\d{4}-\\d{2}-\\d{2}")); // date pattern
|
||||||
|
|
||||||
|
// Checked state (checkbox/radio)
|
||||||
|
assertThat(locator).isChecked();
|
||||||
|
assertThat(locator).not().isChecked();
|
||||||
|
|
||||||
|
// Focused
|
||||||
|
assertThat(locator).isFocused();
|
||||||
|
|
||||||
|
// Editable
|
||||||
|
assertThat(locator).isEditable();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Page Assertions
|
||||||
|
|
||||||
|
```java
|
||||||
|
// URL
|
||||||
|
assertThat(page).hasURL("https://example.com/dashboard");
|
||||||
|
assertThat(page).hasURL(Pattern.compile(".*/dashboard"));
|
||||||
|
|
||||||
|
// Title
|
||||||
|
assertThat(page).hasTitle("Dashboard – MyApp");
|
||||||
|
assertThat(page).hasTitle(Pattern.compile(".*Dashboard.*"));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Negation
|
||||||
|
|
||||||
|
```java
|
||||||
|
// Add .not() for inverse
|
||||||
|
assertThat(locator).not().isVisible();
|
||||||
|
assertThat(locator).not().hasText("Error");
|
||||||
|
assertThat(page).not().hasURL(Pattern.compile(".*/login"));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Custom Timeout on Assertion
|
||||||
|
|
||||||
|
```java
|
||||||
|
assertThat(locator)
|
||||||
|
.hasText("Loaded", new LocatorAssertions.HasTextOptions().setTimeout(10_000));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Soft Assertions (AssertJ)
|
||||||
|
|
||||||
|
Collect all failures before reporting — critical for form validation tests:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
void shouldDisplayAllProfileFields() {
|
||||||
|
ProfilePage profile = new ProfilePage(page());
|
||||||
|
profile.navigate();
|
||||||
|
|
||||||
|
SoftAssertions soft = new SoftAssertions();
|
||||||
|
soft.assertThat(profile.getNameField().inputValue()).isEqualTo("Amal");
|
||||||
|
soft.assertThat(profile.getEmailField().inputValue()).contains("@");
|
||||||
|
soft.assertThat(profile.getRoleLabel().textContent()).isEqualTo("Engineer");
|
||||||
|
soft.assertAll(); // throws at end with ALL failures listed
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Response Assertions
|
||||||
|
|
||||||
|
```java
|
||||||
|
APIResponse response = page().context().request().get("/api/health");
|
||||||
|
assertThat(response).isOK(); // status 200-299
|
||||||
|
assertThat(response).hasStatus(201);
|
||||||
|
assertThat(response).hasHeader("content-type", "application/json");
|
||||||
|
assertThat(response).hasJSON("{\"status\":\"UP\"}"); // exact JSON match
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Screenshot Comparison (Visual Testing)
|
||||||
|
|
||||||
|
```java
|
||||||
|
// Full page screenshot
|
||||||
|
assertThat(page).hasScreenshot(new PageAssertions.HasScreenshotOptions()
|
||||||
|
.setName("dashboard.png")
|
||||||
|
.setFullPage(true)
|
||||||
|
.setThreshold(0.2));
|
||||||
|
|
||||||
|
// Locator screenshot
|
||||||
|
assertThat(page.locator(".chart-container"))
|
||||||
|
.hasScreenshot(new LocatorAssertions.HasScreenshotOptions()
|
||||||
|
.setName("revenue-chart.png"));
|
||||||
|
```
|
||||||
|
|
||||||
|
Update golden files: run with `PLAYWRIGHT_UPDATE_SNAPSHOTS=true mvn test`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Anti-Patterns to Avoid
|
||||||
|
|
||||||
|
```java
|
||||||
|
// ❌ WRONG — no auto-retry, brittle
|
||||||
|
assertTrue(page.locator(".spinner").isHidden());
|
||||||
|
Thread.sleep(2000);
|
||||||
|
|
||||||
|
// ✅ CORRECT — auto-retry until timeout
|
||||||
|
assertThat(page.locator(".spinner")).isHidden();
|
||||||
|
|
||||||
|
// ❌ WRONG — getText() can return stale value
|
||||||
|
String text = locator.textContent();
|
||||||
|
assertEquals("Done", text);
|
||||||
|
|
||||||
|
// ✅ CORRECT — assertion retries until text matches
|
||||||
|
assertThat(locator).hasText("Done");
|
||||||
|
|
||||||
|
// ❌ WRONG — count check without waiting
|
||||||
|
assertEquals(5, page.locator("li").count());
|
||||||
|
|
||||||
|
// ✅ CORRECT — waits until count stabilizes
|
||||||
|
assertThat(page.locator("li")).hasCount(5);
|
||||||
|
```
|
||||||
240
skills/playwright-java/config.md
Normal file
240
skills/playwright-java/config.md
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
# Playwright Java – Project Configuration
|
||||||
|
|
||||||
|
## Maven POM (`pom.xml`)
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
|
||||||
|
http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<groupId>com.company</groupId>
|
||||||
|
<artifactId>playwright-tests</artifactId>
|
||||||
|
<version>1.0-SNAPSHOT</version>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>17</java.version>
|
||||||
|
<maven.compiler.source>17</maven.compiler.source>
|
||||||
|
<maven.compiler.target>17</maven.compiler.target>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
<playwright.version>1.44.0</playwright.version>
|
||||||
|
<junit.version>5.10.2</junit.version>
|
||||||
|
<allure.version>2.27.0</allure.version>
|
||||||
|
<aspectj.version>1.9.22</aspectj.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<!-- Playwright -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.microsoft.playwright</groupId>
|
||||||
|
<artifactId>playwright</artifactId>
|
||||||
|
<version>${playwright.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- JUnit 5 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
<version>${junit.version}</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
<artifactId>junit-jupiter-params</artifactId>
|
||||||
|
<version>${junit.version}</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Allure JUnit5 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.qameta.allure</groupId>
|
||||||
|
<artifactId>allure-junit5</artifactId>
|
||||||
|
<version>${allure.version}</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- AssertJ (for SoftAssertions) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.assertj</groupId>
|
||||||
|
<artifactId>assertj-core</artifactId>
|
||||||
|
<version>3.25.3</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Jackson for JSON test data -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.fasterxml.jackson.core</groupId>
|
||||||
|
<artifactId>jackson-databind</artifactId>
|
||||||
|
<version>2.17.1</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Faker for dynamic test data -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>net.datafaker</groupId>
|
||||||
|
<artifactId>datafaker</artifactId>
|
||||||
|
<version>2.2.2</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- SLF4J + Logback -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>ch.qos.logback</groupId>
|
||||||
|
<artifactId>logback-classic</artifactId>
|
||||||
|
<version>1.5.6</version>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<!-- Surefire with Allure agent + parallel support -->
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
|
<version>3.2.5</version>
|
||||||
|
<configuration>
|
||||||
|
<argLine>
|
||||||
|
-javaagent:"${settings.localRepository}/org/aspectj/aspectjweaver/${aspectj.version}/aspectjweaver-${aspectj.version}.jar"
|
||||||
|
</argLine>
|
||||||
|
<systemPropertyVariables>
|
||||||
|
<browser>${browser}</browser>
|
||||||
|
<headless>${headless}</headless>
|
||||||
|
<baseUrl>${baseUrl}</baseUrl>
|
||||||
|
<allure.results.directory>${project.build.directory}/allure-results</allure.results.directory>
|
||||||
|
</systemPropertyVariables>
|
||||||
|
<properties>
|
||||||
|
<configurationParameters>
|
||||||
|
junit.jupiter.execution.parallel.enabled=true
|
||||||
|
junit.jupiter.execution.parallel.mode.default=concurrent
|
||||||
|
junit.jupiter.execution.parallel.config.strategy=fixed
|
||||||
|
junit.jupiter.execution.parallel.config.fixed.parallelism=4
|
||||||
|
</configurationParameters>
|
||||||
|
</properties>
|
||||||
|
</configuration>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.aspectj</groupId>
|
||||||
|
<artifactId>aspectjweaver</artifactId>
|
||||||
|
<version>${aspectj.version}</version>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</plugin>
|
||||||
|
|
||||||
|
<!-- Allure Maven plugin -->
|
||||||
|
<plugin>
|
||||||
|
<groupId>io.qameta.allure</groupId>
|
||||||
|
<artifactId>allure-maven</artifactId>
|
||||||
|
<version>2.12.0</version>
|
||||||
|
<configuration>
|
||||||
|
<reportVersion>${allure.version}</reportVersion>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `test.properties`
|
||||||
|
|
||||||
|
```properties
|
||||||
|
# src/test/resources/test.properties
|
||||||
|
baseUrl=https://your-app.com
|
||||||
|
apiBaseUrl=https://api.your-app.com
|
||||||
|
browser=chromium
|
||||||
|
headless=true
|
||||||
|
slowMo=0
|
||||||
|
defaultTimeout=30000
|
||||||
|
navigationTimeout=60000
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `ConfigReader.java`
|
||||||
|
|
||||||
|
```java
|
||||||
|
package com.company.tests.config;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.util.Properties;
|
||||||
|
|
||||||
|
public final class ConfigReader {
|
||||||
|
private static final Properties props = new Properties();
|
||||||
|
|
||||||
|
static {
|
||||||
|
try (InputStream in = ConfigReader.class
|
||||||
|
.getClassLoader()
|
||||||
|
.getResourceAsStream("test.properties")) {
|
||||||
|
if (in != null) props.load(in);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException("Cannot load test.properties", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String getBaseUrl() {
|
||||||
|
return System.getProperty("baseUrl", props.getProperty("baseUrl", "http://localhost:3000"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int getDefaultTimeout() {
|
||||||
|
String val = System.getProperty("defaultTimeout", props.getProperty("defaultTimeout", "30000"));
|
||||||
|
return Integer.parseInt(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isHeadless() {
|
||||||
|
return Boolean.parseBoolean(System.getProperty("headless", props.getProperty("headless", "true")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Browser Installation (First-Time Setup)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install browsers for current OS
|
||||||
|
mvn exec:java -e -Dexec.mainClass=com.microsoft.playwright.CLI -Dexec.args="install"
|
||||||
|
|
||||||
|
# Install with system dependencies (needed in CI/Docker)
|
||||||
|
mvn exec:java -e -Dexec.mainClass=com.microsoft.playwright.CLI -Dexec.args="install --with-deps"
|
||||||
|
|
||||||
|
# Install specific browser only
|
||||||
|
mvn exec:java -e -Dexec.mainClass=com.microsoft.playwright.CLI -Dexec.args="install chromium"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# All tests, headless Chromium
|
||||||
|
mvn test
|
||||||
|
|
||||||
|
# Specific browser
|
||||||
|
mvn test -Dbrowser=firefox
|
||||||
|
|
||||||
|
# Headed mode (debug)
|
||||||
|
mvn test -Dheadless=false -DslowMo=500
|
||||||
|
|
||||||
|
# Single test class
|
||||||
|
mvn test -Dtest=LoginTest
|
||||||
|
|
||||||
|
# Generate Allure report
|
||||||
|
mvn allure:serve
|
||||||
|
|
||||||
|
# Export report to directory
|
||||||
|
mvn allure:report
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker / CI Environment
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM mcr.microsoft.com/playwright/java:v1.44.0-jammy
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
RUN mvn dependency:resolve
|
||||||
|
CMD ["mvn", "test", "-Dheadless=true"]
|
||||||
|
```
|
||||||
263
skills/playwright-java/fixtures.md
Normal file
263
skills/playwright-java/fixtures.md
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
# Playwright Java – Fixtures, Hooks & Test Data
|
||||||
|
|
||||||
|
## JUnit 5 Extension for Playwright (Custom Fixture)
|
||||||
|
|
||||||
|
Encapsulate browser lifecycle in a reusable JUnit 5 extension:
|
||||||
|
|
||||||
|
```java
|
||||||
|
package com.company.tests.base;
|
||||||
|
|
||||||
|
import com.microsoft.playwright.*;
|
||||||
|
import org.junit.jupiter.api.extension.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
public class PlaywrightExtension
|
||||||
|
implements BeforeEachCallback, AfterEachCallback, ParameterResolver {
|
||||||
|
|
||||||
|
private static final Map<String, Page> pageMap = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void beforeEach(ExtensionContext ctx) {
|
||||||
|
Playwright pw = Playwright.create();
|
||||||
|
Browser browser = pw.chromium().launch(new BrowserType.LaunchOptions().setHeadless(true));
|
||||||
|
BrowserContext context = browser.newContext();
|
||||||
|
Page page = context.newPage();
|
||||||
|
pageMap.put(ctx.getUniqueId(), page);
|
||||||
|
|
||||||
|
ctx.getStore(ExtensionContext.Namespace.GLOBAL).put("playwright", pw);
|
||||||
|
ctx.getStore(ExtensionContext.Namespace.GLOBAL).put("browser", browser);
|
||||||
|
ctx.getStore(ExtensionContext.Namespace.GLOBAL).put("context", context);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterEach(ExtensionContext ctx) {
|
||||||
|
pageMap.remove(ctx.getUniqueId());
|
||||||
|
closeIfNotNull(ctx.getStore(ExtensionContext.Namespace.GLOBAL).remove("context", BrowserContext.class));
|
||||||
|
closeIfNotNull(ctx.getStore(ExtensionContext.Namespace.GLOBAL).remove("browser", Browser.class));
|
||||||
|
closeIfNotNull(ctx.getStore(ExtensionContext.Namespace.GLOBAL).remove("playwright", Playwright.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean supportsParameter(ParameterContext param, ExtensionContext ext) {
|
||||||
|
return param.getParameter().getType() == Page.class;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Object resolveParameter(ParameterContext param, ExtensionContext ext) {
|
||||||
|
return pageMap.get(ext.getUniqueId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void closeIfNotNull(AutoCloseable obj) {
|
||||||
|
if (obj != null) try { obj.close(); } catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage:
|
||||||
|
@ExtendWith(PlaywrightExtension.class)
|
||||||
|
class CheckoutTest {
|
||||||
|
@Test
|
||||||
|
void shouldCompleteCheckout(Page page) {
|
||||||
|
// Page is injected automatically
|
||||||
|
new LoginPage(page).navigate().loginAs("user@test.com", "pass");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Data Factory
|
||||||
|
|
||||||
|
```java
|
||||||
|
package com.company.tests.utils;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import net.datafaker.Faker;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public final class TestDataFactory {
|
||||||
|
private static final Faker faker = new Faker();
|
||||||
|
private static final ObjectMapper mapper = new ObjectMapper();
|
||||||
|
|
||||||
|
// --- Static test data from JSON ---
|
||||||
|
public static User getDefaultUser() {
|
||||||
|
return loadUsers().stream()
|
||||||
|
.filter(u -> u.role().equals("default"))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(() -> new RuntimeException("No default user in testdata/users.json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static User getAdminUser() {
|
||||||
|
return loadUsers().stream()
|
||||||
|
.filter(u -> u.role().equals("admin"))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private static List<User> loadUsers() {
|
||||||
|
try (InputStream in = TestDataFactory.class
|
||||||
|
.getClassLoader().getResourceAsStream("testdata/users.json")) {
|
||||||
|
return mapper.readValue(in, mapper.getTypeFactory()
|
||||||
|
.constructCollectionType(List.class, User.class));
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Failed to load users.json", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Dynamic data generation ---
|
||||||
|
public static User generateRandomUser() {
|
||||||
|
return new User(
|
||||||
|
faker.internet().emailAddress(),
|
||||||
|
faker.internet().password(12, 20, true, true, true),
|
||||||
|
faker.name().firstName(),
|
||||||
|
faker.name().lastName(),
|
||||||
|
"default"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String randomPhone() {
|
||||||
|
return faker.phoneNumber().cellPhone();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String randomPostalCode() {
|
||||||
|
return faker.address().zipCode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `testdata/users.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"email": "admin@company.com",
|
||||||
|
"password": "Admin@1234",
|
||||||
|
"firstName": "Admin",
|
||||||
|
"lastName": "User",
|
||||||
|
"role": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"email": "user@company.com",
|
||||||
|
"password": "User@1234",
|
||||||
|
"firstName": "Test",
|
||||||
|
"lastName": "User",
|
||||||
|
"role": "default"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pre-Authenticated Context (Reuse Auth State)
|
||||||
|
|
||||||
|
Save login state once, reuse across tests — massive speed improvement:
|
||||||
|
|
||||||
|
```java
|
||||||
|
// Run once before test suite (e.g., in @BeforeAll or a setup class)
|
||||||
|
public class AuthSetup {
|
||||||
|
public static void saveAuthState() {
|
||||||
|
try (Playwright pw = Playwright.create()) {
|
||||||
|
Browser browser = pw.chromium().launch();
|
||||||
|
BrowserContext context = browser.newContext();
|
||||||
|
Page page = context.newPage();
|
||||||
|
|
||||||
|
page.navigate(ConfigReader.getBaseUrl() + "/login");
|
||||||
|
page.getByLabel("Email").fill(TestDataFactory.getDefaultUser().email());
|
||||||
|
page.getByLabel("Password").fill(TestDataFactory.getDefaultUser().password());
|
||||||
|
page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Sign in")).click();
|
||||||
|
page.waitForURL("**/dashboard");
|
||||||
|
|
||||||
|
// Save storage state (cookies + localStorage)
|
||||||
|
context.storageState(new BrowserContext.StorageStateOptions()
|
||||||
|
.setPath(Paths.get("target/auth/user-state.json")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// In BaseTest, load saved state:
|
||||||
|
BrowserContext context = browser.newContext(new Browser.NewContextOptions()
|
||||||
|
.setStorageStatePath(Paths.get("target/auth/user-state.json")));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Screenshot on Test Failure (Allure Attachment)
|
||||||
|
|
||||||
|
```java
|
||||||
|
// Add this to @AfterEach in BaseTest
|
||||||
|
@AfterEach
|
||||||
|
void captureOnFailure(TestInfo info) {
|
||||||
|
// The test outcome is available via TestInfo with JUnit5 + Allure
|
||||||
|
byte[] screenshot = page().screenshot(new Page.ScreenshotOptions().setFullPage(true));
|
||||||
|
Allure.addAttachment("Screenshot on failure", "image/png",
|
||||||
|
new ByteArrayInputStream(screenshot), "png");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## WaitUtils Helper
|
||||||
|
|
||||||
|
```java
|
||||||
|
package com.company.tests.utils;
|
||||||
|
|
||||||
|
import com.microsoft.playwright.Page;
|
||||||
|
|
||||||
|
public final class WaitUtils {
|
||||||
|
|
||||||
|
public static void waitForSpinnerToDisappear(Page page) {
|
||||||
|
page.locator(".loading-spinner").waitFor(
|
||||||
|
new Locator.WaitForOptions()
|
||||||
|
.setState(WaitForSelectorState.HIDDEN)
|
||||||
|
.setTimeout(15_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void waitForToastMessage(Page page, String message) {
|
||||||
|
page.getByRole(AriaRole.ALERT)
|
||||||
|
.filter(new Locator.FilterOptions().setHasText(message))
|
||||||
|
.waitFor(new Locator.WaitForOptions()
|
||||||
|
.setState(WaitForSelectorState.VISIBLE)
|
||||||
|
.setTimeout(5_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void waitForApiResponse(Page page, String urlPattern) {
|
||||||
|
page.waitForResponse(resp ->
|
||||||
|
resp.url().contains(urlPattern) && resp.status() < 400,
|
||||||
|
() -> {}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Retry Logic for Flaky Tests
|
||||||
|
|
||||||
|
```java
|
||||||
|
// JUnit 5 built-in retry extension
|
||||||
|
// Add to pom.xml:
|
||||||
|
// <dependency>
|
||||||
|
// <groupId>org.junit.jupiter</groupId>
|
||||||
|
// <artifactId>junit-jupiter-engine</artifactId>
|
||||||
|
// <version>5.10.2</version>
|
||||||
|
// </dependency>
|
||||||
|
|
||||||
|
@RepeatedTest(value = 3, failureThreshold = 1) // retry up to 3 times
|
||||||
|
void flakySmokeTest() {
|
||||||
|
// test body
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom @RetryTest annotation backed by a JUnit Extension:
|
||||||
|
@Target(ElementType.METHOD)
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
@ExtendWith(RetryExtension.class)
|
||||||
|
public @interface RetryTest {
|
||||||
|
int times() default 3;
|
||||||
|
}
|
||||||
|
```
|
||||||
224
skills/playwright-java/page-objects.md
Normal file
224
skills/playwright-java/page-objects.md
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
# Playwright Java – Page Object Patterns
|
||||||
|
|
||||||
|
## Component Pattern (Reusable Sub-Page-Objects)
|
||||||
|
|
||||||
|
For repeated UI components (navbars, modals, tables), create Component classes:
|
||||||
|
|
||||||
|
```java
|
||||||
|
// Reusable table component — pass the locator root
|
||||||
|
public class DataTable extends BasePage {
|
||||||
|
private final Locator tableRoot;
|
||||||
|
|
||||||
|
public DataTable(Page page, Locator tableRoot) {
|
||||||
|
super(page);
|
||||||
|
this.tableRoot = tableRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getRowCount() {
|
||||||
|
return tableRoot.locator("tbody tr").count();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCellValue(int row, int col) {
|
||||||
|
return tableRoot.locator("tbody tr")
|
||||||
|
.nth(row)
|
||||||
|
.locator("td")
|
||||||
|
.nth(col)
|
||||||
|
.innerText();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void clickRowAction(int row, String actionLabel) {
|
||||||
|
tableRoot.locator("tbody tr")
|
||||||
|
.nth(row)
|
||||||
|
.getByRole(AriaRole.BUTTON, new Locator.GetByRoleOptions().setName(actionLabel))
|
||||||
|
.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
public DataTable sortByColumn(String columnHeader) {
|
||||||
|
tableRoot.getByRole(AriaRole.COLUMNHEADER,
|
||||||
|
new Locator.GetByRoleOptions().setName(columnHeader)).click();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage in a page object:
|
||||||
|
public class UsersPage extends BasePage {
|
||||||
|
public final DataTable usersTable;
|
||||||
|
private final Locator searchInput;
|
||||||
|
private final Locator addUserButton;
|
||||||
|
|
||||||
|
public UsersPage(Page page) {
|
||||||
|
super(page);
|
||||||
|
usersTable = new DataTable(page, page.locator("#users-table"));
|
||||||
|
searchInput = page.getByPlaceholder("Search users…");
|
||||||
|
addUserButton = page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Add User"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override protected String getUrl() { return "/admin/users"; }
|
||||||
|
|
||||||
|
public UsersPage searchFor(String query) {
|
||||||
|
searchInput.fill(query);
|
||||||
|
searchInput.press("Enter");
|
||||||
|
page.waitForResponse("**/api/users**", () -> {});
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Modal / Dialog Component
|
||||||
|
|
||||||
|
```java
|
||||||
|
public class ConfirmDialog extends BasePage {
|
||||||
|
private final Locator dialog;
|
||||||
|
private final Locator confirmButton;
|
||||||
|
private final Locator cancelButton;
|
||||||
|
private final Locator titleText;
|
||||||
|
|
||||||
|
public ConfirmDialog(Page page) {
|
||||||
|
super(page);
|
||||||
|
dialog = page.getByRole(AriaRole.DIALOG);
|
||||||
|
confirmButton = dialog.getByRole(AriaRole.BUTTON, new Locator.GetByRoleOptions().setName("Confirm"));
|
||||||
|
cancelButton = dialog.getByRole(AriaRole.BUTTON, new Locator.GetByRoleOptions().setName("Cancel"));
|
||||||
|
titleText = dialog.getByRole(AriaRole.HEADING);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void waitForOpen() {
|
||||||
|
dialog.waitFor(new Locator.WaitForOptions().setState(WaitForSelectorState.VISIBLE));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void confirm() { confirmButton.click(); dialog.waitFor(
|
||||||
|
new Locator.WaitForOptions().setState(WaitForSelectorState.HIDDEN)); }
|
||||||
|
|
||||||
|
public void cancel() { cancelButton.click(); }
|
||||||
|
|
||||||
|
public String getTitle() { return titleText.innerText(); }
|
||||||
|
|
||||||
|
@Override protected String getUrl() { return ""; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Navigation Chain Pattern
|
||||||
|
|
||||||
|
Page methods should **return the next page object** — never return `void` for navigating actions:
|
||||||
|
|
||||||
|
```java
|
||||||
|
// CORRECT — enables fluent chaining
|
||||||
|
public DashboardPage loginAs(String email, String password) {
|
||||||
|
fill(emailInput, email);
|
||||||
|
fill(passwordInput, password);
|
||||||
|
clickAndWaitForNav(submitButton);
|
||||||
|
return new DashboardPage(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
// USAGE
|
||||||
|
DashboardPage dash = new LoginPage(page)
|
||||||
|
.navigate()
|
||||||
|
.loginAs("user@test.com", "secret")
|
||||||
|
.navigateTo(OrdersPage::new) // helper to reduce boilerplate
|
||||||
|
.filterByStatus("PENDING");
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dynamic Locators
|
||||||
|
|
||||||
|
For lists of items that share structure:
|
||||||
|
|
||||||
|
```java
|
||||||
|
// Locate a card by its title
|
||||||
|
public Locator getProductCard(String productName) {
|
||||||
|
return page.locator(".product-card")
|
||||||
|
.filter(new Locator.FilterOptions().setHasText(productName));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for a specific row to appear in a table
|
||||||
|
public void waitForOrderRow(String orderId) {
|
||||||
|
page.locator("tr[data-order-id='" + orderId + "']")
|
||||||
|
.waitFor(new Locator.WaitForOptions().setState(WaitForSelectorState.VISIBLE)
|
||||||
|
.setTimeout(15_000));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dropdown / Select Handling
|
||||||
|
|
||||||
|
```java
|
||||||
|
// Native <select>
|
||||||
|
page.selectOption("#country-select", "India");
|
||||||
|
|
||||||
|
// Custom dropdown (non-native)
|
||||||
|
public void selectCountry(String countryName) {
|
||||||
|
page.getByLabel("Country").click();
|
||||||
|
page.getByRole(AriaRole.LISTBOX)
|
||||||
|
.getByText(countryName)
|
||||||
|
.click();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Upload
|
||||||
|
|
||||||
|
```java
|
||||||
|
// Standard file input
|
||||||
|
page.setInputFiles("#file-upload", Paths.get("src/test/resources/testdata/sample.pdf"));
|
||||||
|
|
||||||
|
// Drag-and-drop upload zone
|
||||||
|
page.locator(".upload-zone").setInputFiles(Paths.get("src/test/resources/testdata/sample.pdf"));
|
||||||
|
|
||||||
|
// Multiple files
|
||||||
|
page.setInputFiles("#file-upload", new Path[]{
|
||||||
|
Paths.get("file1.png"),
|
||||||
|
Paths.get("file2.png")
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Download Handling
|
||||||
|
|
||||||
|
```java
|
||||||
|
Download download = page.waitForDownload(() ->
|
||||||
|
page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Export CSV")).click()
|
||||||
|
);
|
||||||
|
Path downloadedFile = download.path();
|
||||||
|
assertThat(downloadedFile.toFile()).exists();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hover & Tooltip Verification
|
||||||
|
|
||||||
|
```java
|
||||||
|
page.getByTestId("info-icon").hover();
|
||||||
|
Locator tooltip = page.getByRole(AriaRole.TOOLTIP);
|
||||||
|
tooltip.waitFor();
|
||||||
|
assertThat(tooltip).hasText("This field is required");
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Waiting Strategies (Anti-Flake)
|
||||||
|
|
||||||
|
```java
|
||||||
|
// Wait for API response after action
|
||||||
|
page.waitForResponse(resp -> resp.url().contains("/api/search") && resp.status() == 200,
|
||||||
|
() -> searchInput.fill("test"));
|
||||||
|
|
||||||
|
// Wait for network idle (after complex renders)
|
||||||
|
page.waitForLoadState(LoadState.NETWORKIDLE);
|
||||||
|
|
||||||
|
// Wait for element count to stabilize
|
||||||
|
Locator rows = page.locator("tbody tr");
|
||||||
|
rows.first().waitFor(); // wait for at least one
|
||||||
|
assertThat(rows).hasCount(10);
|
||||||
|
|
||||||
|
// Polling custom condition
|
||||||
|
Assertions.assertDoesNotThrow(() -> {
|
||||||
|
page.waitForCondition(() -> page.locator(".spinner").count() == 0);
|
||||||
|
});
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user