diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index d3dc7e0..acd880a 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -2,6 +2,9 @@ name: Tests
on: [push]
+permissions:
+ contents: read
+
jobs:
lint:
name: Lint
@@ -81,3 +84,44 @@ jobs:
SERVER_URL: https://axe.dequelabs.com
CHROME_BIN: ${{ steps.install-chrome.outputs.chrome-path }}
CHROMEDRIVER_BIN: ${{ steps.install-chrome.outputs.chromedriver-path }}
+
+ java-smoke-tests:
+ name: Java Smoke Tests
+ needs: lint
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ strategy:
+ # We don't want to fail the whole matrix if one job fails
+ # Makes it harder to debug what went wrong if it ends early
+ fail-fast: false
+ matrix:
+ directory:
+ - java/playwright/basic
+ - java/playwright/manual-mode
+ - java/playwright/context-wrapping
+ - java/playwright/multi-page
+ steps:
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
+ with:
+ distribution: temurin
+ java-version: 17
+ cache: maven
+ - run: mkdir -p ./tmp/browsers
+ - uses: ./.github/actions/install-chrome
+ id: install-chrome
+ with:
+ working-directory: ./tmp/browsers
+ chrome-version: ${{ vars.CHROME_VERSION || 'stable' }}
+ - name: Run tests
+ run: |
+ cd ${{ matrix.directory }} &&
+ mvn -B test
+ env:
+ API_KEY: ${{ secrets.AXE_DEVHUB_API_KEY }}
+ PROJECT_ID: ${{ secrets.PROJECT_ID }}
+ SERVER_URL: https://axe.dequelabs.com
+ # Point Playwright Java at the runner's Chrome; the bundled-browser download is skipped
+ # since axe Watcher loads its extension into this binary via the `chrome` channel.
+ CHROME_BIN: ${{ steps.install-chrome.outputs.chrome-path }}
+ PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
diff --git a/README.md b/README.md
index 8af8839..e52de51 100644
--- a/README.md
+++ b/README.md
@@ -18,3 +18,10 @@
```sh
SERVER_URL="https://axe.yourcompany.com" API_KEY="YOUR API KEY" PROJECT_ID="YOUR PROJECT ID" npm test
```
+
+## Java examples
+
+The [`java`](./java) directory holds Maven projects built and run with `mvn test` instead of npm.
+See [`java/playwright`](./java/playwright) for the Playwright Java examples (auto analysis, manual
+mode, and context wrapping) and its [README](./java/playwright/README.md) for prerequisites and run
+instructions.
diff --git a/java/playwright/README.md b/java/playwright/README.md
new file mode 100644
index 0000000..22836de
--- /dev/null
+++ b/java/playwright/README.md
@@ -0,0 +1,80 @@
+# axe Watcher — Playwright Java examples
+
+Runnable [JUnit 5](https://junit.org/junit5/) projects showing how to add accessibility analysis to
+a [Playwright Java](https://playwright.dev/java/) test suite with
+[`@axe-core/watcher`](https://central.sonatype.com/artifact/com.deque.axe_core/watcher). axe Watcher
+wraps your Playwright `Page` (or `BrowserContext`) so scans run automatically as your existing
+end-to-end tests drive the browser, then uploads the results to
+[axe Developer Hub](https://docs.deque.com/developer-hub).
+
+| Example | Scenario | What it shows |
+| ---------------------------------------- | ---------------- | --------------------------------------------------------------------------------- |
+| [`basic`](./basic) | Auto analysis | The default mode — wrap the page and every interaction is analyzed automatically. |
+| [`manual-mode`](./manual-mode) | Manual mode | `setAutoAnalyze(false)` plus `analyze()` / `start()` / `stop()` to control scans. |
+| [`context-wrapping`](./context-wrapping) | Context wrapping | `wrapContext()` so every page opened from one `BrowserContext` is instrumented. |
+| [`multi-page`](./multi-page) | Multi-page | Reuse one wrapped page across several pages, flushing after each test. |
+
+## Prerequisites
+
+- **Java 11 or newer.** The axe Watcher integration itself supports Java 8+; these example projects
+ target Java 11 source compatibility and are tested on Java 17 in CI.
+- **[Maven](https://maven.apache.org/).** Each example is a standalone Maven project.
+- **A Chromium-based browser.** axe Watcher loads a browser extension, which Chromium only supports
+ when launched via a persistent context using [Chrome for
+ Testing](https://developer.chrome.com/blog/chrome-for-testing), Playwright's bundled Chromium, or
+ a Microsoft Edge channel. Recent branded Google Chrome releases (139+) restrict the
+ `--load-extension` flag axe Watcher relies on, so prefer Chrome for Testing or Chromium.
+- **An axe Developer Hub API key and project ID.**
+ - [Create an API key](https://axe.deque.com/settings)
+ - [Create a project ID](https://axe.deque.com/axe-watcher/projects)
+
+## Running an example
+
+1. Provide your credentials as environment variables. `SERVER_URL` is optional and defaults to
+ `https://axe.deque.com`; set it to target a different axe Developer Hub instance.
+
+ ```sh
+ export API_KEY="YOUR API KEY"
+ export PROJECT_ID="YOUR PROJECT ID"
+ # export SERVER_URL="https://axe.yourcompany.com"
+ ```
+
+2. Change into the example you want to run:
+
+ ```sh
+ cd basic
+ ```
+
+3. (Local runs only.) Install Playwright's bundled Chromium once. Skip this if you instead set
+ `CHROME_BIN` to a Chrome for Testing (or Chromium) binary — the examples will launch that, which
+ is how they run in CI.
+
+ ```sh
+ mvn exec:java -Dexec.mainClass=com.microsoft.playwright.CLI -Dexec.args="install chromium"
+ ```
+
+4. Run the tests:
+
+ ```sh
+ mvn test
+ ```
+
+5. Open your project in [axe Developer Hub](https://docs.deque.com/developer-hub) to review the
+ captured page states and issues.
+
+## Notes
+
+- **Headless mode.** The examples run in Chromium's "new" headless mode (`setHeadless(true)` with a
+ Chromium-based channel) so they work in CI. axe Watcher's extension cannot load in Chromium's
+ classic/default headless mode, so `configure()` rejects a plain `--headless` argument, an
+ `--incognito` argument, or `setHeadless(true)` without a supported channel. Call
+ `setHeadless(false)` to watch a run in a visible window.
+- **Always `flush()`.** Results are only uploaded when you flush. The examples call
+ `page.axeWatcher().flush()` in an `@AfterEach` hook (the `context-wrapping` example relies on
+ `BrowserContext.close()`, which flushes every tracked page).
+- **Attributing scans to a test.** To trace a violation back to the test that produced it, call
+ `page.axeWatcher().setTestContext(testFilePath, testLocation)` — typically from a `@BeforeEach`
+ hook using JUnit 5's `TestInfo`.
+
+For the full API and configuration reference, see the
+[`@axe-core/watcher` Java documentation](https://central.sonatype.com/artifact/com.deque.axe_core/watcher).
diff --git a/java/playwright/basic/pom.xml b/java/playwright/basic/pom.xml
new file mode 100644
index 0000000..1a1e440
--- /dev/null
+++ b/java/playwright/basic/pom.xml
@@ -0,0 +1,50 @@
+
+
+ 4.0.0
+
+ com.deque.watcher_examples.playwright
+ basic
+ 1.0-SNAPSHOT
+
+
+ 11
+ 11
+ UTF-8
+
+
+
+
+
+ com.microsoft.playwright
+ playwright
+ 1.60.0
+
+
+ com.deque.axe_core
+ watcher
+ 4.4.0
+
+
+ org.junit.jupiter
+ junit-jupiter
+ 5.11.3
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.2.5
+
+
+
+
+
diff --git a/java/playwright/basic/src/test/java/com/deque/watcher_examples/playwright/BasicTest.java b/java/playwright/basic/src/test/java/com/deque/watcher_examples/playwright/BasicTest.java
new file mode 100644
index 0000000..28b74e4
--- /dev/null
+++ b/java/playwright/basic/src/test/java/com/deque/watcher_examples/playwright/BasicTest.java
@@ -0,0 +1,116 @@
+package com.deque.watcher_examples.playwright;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import com.deque.axe_core.commons.AxeWatcherOptions;
+import com.deque.axe_core.playwright.AxeWatcherPage;
+import com.deque.axe_core.playwright.AxeWatcherPlaywright;
+import com.microsoft.playwright.BrowserContext;
+import com.microsoft.playwright.BrowserType;
+import com.microsoft.playwright.Playwright;
+import java.nio.file.Paths;
+import java.util.Arrays;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+/*
+ Auto analysis (the default mode).
+
+ Once the Playwright `Page` is wrapped with `wrapPage()`, axe Watcher analyzes each page state
+ automatically: every wrapped interaction (navigation, click, fill, ...) triggers a scan, and the
+ page is re-analyzed whenever the DOM changes. The rest of the test is plain Playwright Java.
+*/
+@DisplayName("My Login Application")
+class BasicTest {
+
+ Playwright playwright;
+ BrowserContext context;
+ AxeWatcherPage page;
+
+ @BeforeEach
+ void setUp() {
+ AxeWatcherPlaywright axeWatcher =
+ new AxeWatcherPlaywright(
+ new AxeWatcherOptions()
+ .setApiKey(System.getenv("API_KEY"))
+ .setProjectId(System.getenv("PROJECT_ID"))
+ .setServerUrl(serverUrl()))
+ .enableDebugLogger();
+
+ // configure() merges the axe Watcher extension flags into the launch options. It must be
+ // called before launching the persistent context — Chromium only loads extensions when
+ // launched via a persistent context.
+ BrowserType.LaunchPersistentContextOptions launchOptions =
+ axeWatcher.configure(browserOptions());
+
+ playwright = Playwright.create();
+ // An empty path tells Playwright to use a temporary profile directory.
+ context = playwright.chromium().launchPersistentContext(Paths.get(""), launchOptions);
+
+ // Interactions on a wrapped page are analyzed automatically.
+ page = axeWatcher.wrapPage(context.newPage());
+ }
+
+ @AfterEach
+ void tearDown() {
+ // Send the collected results to axe Developer Hub after each test.
+ page.axeWatcher().flush();
+ context.close();
+ playwright.close();
+ }
+
+ @Nested
+ @DisplayName("Login")
+ class LoginTests {
+ @Nested
+ @DisplayName("with valid credentials")
+ class ShouldLoginTests {
+ @Test
+ @DisplayName("should login")
+ void shouldLoginTest() {
+ page.navigate("https://the-internet.herokuapp.com/login");
+
+ page.locator("#username").fill("tomsmith");
+ page.locator("#password").fill("SuperSecretPassword!");
+
+ page.locator("button[type='submit']").click();
+
+ assertNotNull(page.waitForSelector("#flash"));
+ }
+ }
+ }
+
+ private static String serverUrl() {
+ String serverUrl = System.getenv("SERVER_URL");
+ return serverUrl != null ? serverUrl : "https://axe.deque.com";
+ }
+
+ private static BrowserType.LaunchPersistentContextOptions browserOptions() {
+ /*
+ axe Watcher's browser extension loads only in a headed browser or Chromium's "new"
+ headless mode — never Chromium's classic/default headless mode. We run headless here
+ (with a Chromium-based channel, selected below, which new-headless requires) so the
+ example works in CI; call setHeadless(false) to watch the run in a visible window.
+ "--no-sandbox" lets Chromium run as root in CI and isn't needed for local headed runs.
+ */
+ BrowserType.LaunchPersistentContextOptions options =
+ new BrowserType.LaunchPersistentContextOptions()
+ .setHeadless(true)
+ .setArgs(Arrays.asList("--no-sandbox"));
+
+ // In CI we point Playwright at the Chrome installed on the runner (CHROME_BIN). Locally,
+ // without CHROME_BIN, Playwright falls back to its bundled Chromium — install it with
+ // `mvn exec:java -Dexec.mainClass=com.microsoft.playwright.CLI -Dexec.args="install chromium"`.
+ String chromeBin = System.getenv("CHROME_BIN");
+ if (chromeBin != null) {
+ options.setChannel("chrome").setExecutablePath(Paths.get(chromeBin));
+ } else {
+ options.setChannel("chromium");
+ }
+
+ return options;
+ }
+}
diff --git a/java/playwright/context-wrapping/pom.xml b/java/playwright/context-wrapping/pom.xml
new file mode 100644
index 0000000..3e9a6e6
--- /dev/null
+++ b/java/playwright/context-wrapping/pom.xml
@@ -0,0 +1,50 @@
+
+
+ 4.0.0
+
+ com.deque.watcher_examples.playwright
+ context-wrapping
+ 1.0-SNAPSHOT
+
+
+ 11
+ 11
+ UTF-8
+
+
+
+
+
+ com.microsoft.playwright
+ playwright
+ 1.60.0
+
+
+ com.deque.axe_core
+ watcher
+ 4.4.0
+
+
+ org.junit.jupiter
+ junit-jupiter
+ 5.11.3
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.2.5
+
+
+
+
+
diff --git a/java/playwright/context-wrapping/src/test/java/com/deque/watcher_examples/playwright/ContextWrappingTest.java b/java/playwright/context-wrapping/src/test/java/com/deque/watcher_examples/playwright/ContextWrappingTest.java
new file mode 100644
index 0000000..ed2706d
--- /dev/null
+++ b/java/playwright/context-wrapping/src/test/java/com/deque/watcher_examples/playwright/ContextWrappingTest.java
@@ -0,0 +1,116 @@
+package com.deque.watcher_examples.playwright;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.deque.axe_core.commons.AxeWatcherOptions;
+import com.deque.axe_core.playwright.AxeWatcherBrowserContext;
+import com.deque.axe_core.playwright.AxeWatcherPage;
+import com.deque.axe_core.playwright.AxeWatcherPlaywright;
+import com.microsoft.playwright.BrowserType;
+import com.microsoft.playwright.Playwright;
+import java.nio.file.Paths;
+import java.util.Arrays;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/*
+ Wrapping a BrowserContext.
+
+ When a test opens several pages, wrap the BrowserContext once with wrapContext() instead of
+ calling wrapPage() for each page. Every page created from a wrapped context (via newPage()) is
+ automatically instrumented, and closing the context flushes results for every page it opened.
+*/
+@DisplayName("My Multi-page Application")
+class ContextWrappingTest {
+
+ Playwright playwright;
+ AxeWatcherBrowserContext context;
+
+ @BeforeEach
+ void setUp() {
+ AxeWatcherPlaywright axeWatcher =
+ new AxeWatcherPlaywright(
+ new AxeWatcherOptions()
+ .setApiKey(System.getenv("API_KEY"))
+ .setProjectId(System.getenv("PROJECT_ID"))
+ .setServerUrl(serverUrl()))
+ .enableDebugLogger();
+
+ // configure() merges the axe Watcher extension flags into the launch options. It must be
+ // called before launching the persistent context — Chromium only loads extensions when
+ // launched via a persistent context.
+ BrowserType.LaunchPersistentContextOptions launchOptions =
+ axeWatcher.configure(browserOptions());
+
+ playwright = Playwright.create();
+
+ // wrapContext() requires configure() to have run first. An empty path tells Playwright to
+ // use a temporary profile directory.
+ context =
+ axeWatcher.wrapContext(
+ playwright.chromium().launchPersistentContext(Paths.get(""), launchOptions));
+ }
+
+ @AfterEach
+ void tearDown() {
+ // Closing the wrapped context flushes results for every page opened from it.
+ context.close();
+ playwright.close();
+ }
+
+ @Test
+ @DisplayName("analyzes every page opened from the wrapped context")
+ void analyzesEveryPageTest() {
+ // Pages from a wrapped context are already instrumented — no per-page wrapPage() call.
+ AxeWatcherPage homePage = (AxeWatcherPage) context.newPage();
+ homePage.navigate("https://the-internet.herokuapp.com");
+ // count() does not auto-wait, so wait for the link list to render before counting.
+ homePage.locator("ul li a").first().waitFor();
+ int linkCount = homePage.locator("ul li a").count();
+ assertTrue(linkCount >= 20, "expected the home page link list to load, got " + linkCount);
+
+ // A second page opened from the same context is instrumented too.
+ AxeWatcherPage loginPage = (AxeWatcherPage) context.newPage();
+ loginPage.navigate("https://the-internet.herokuapp.com/login");
+
+ loginPage.locator("#username").fill("tomsmith");
+ loginPage.locator("#password").fill("SuperSecretPassword!");
+ loginPage.locator("button[type='submit']").click();
+
+ assertNotNull(loginPage.waitForSelector("#flash"));
+ }
+
+ private static String serverUrl() {
+ String serverUrl = System.getenv("SERVER_URL");
+ return serverUrl != null ? serverUrl : "https://axe.deque.com";
+ }
+
+ private static BrowserType.LaunchPersistentContextOptions browserOptions() {
+ /*
+ axe Watcher's browser extension loads only in a headed browser or Chromium's "new"
+ headless mode — never Chromium's classic/default headless mode. We run headless here
+ (with a Chromium-based channel, selected below, which new-headless requires) so the
+ example works in CI; call setHeadless(false) to watch the run in a visible window.
+ "--no-sandbox" lets Chromium run as root in CI and isn't needed for local headed runs.
+ */
+ BrowserType.LaunchPersistentContextOptions options =
+ new BrowserType.LaunchPersistentContextOptions()
+ .setHeadless(true)
+ .setArgs(Arrays.asList("--no-sandbox"));
+
+ // In CI we point Playwright at the Chrome installed on the runner (CHROME_BIN). Locally,
+ // without CHROME_BIN, Playwright falls back to its bundled Chromium — install it with
+ // `mvn exec:java -Dexec.mainClass=com.microsoft.playwright.CLI -Dexec.args="install chromium"`.
+ String chromeBin = System.getenv("CHROME_BIN");
+ if (chromeBin != null) {
+ options.setChannel("chrome").setExecutablePath(Paths.get(chromeBin));
+ } else {
+ options.setChannel("chromium");
+ }
+
+ return options;
+ }
+}
diff --git a/java/playwright/manual-mode/pom.xml b/java/playwright/manual-mode/pom.xml
new file mode 100644
index 0000000..41c19f3
--- /dev/null
+++ b/java/playwright/manual-mode/pom.xml
@@ -0,0 +1,50 @@
+
+
+ 4.0.0
+
+ com.deque.watcher_examples.playwright
+ manual-mode
+ 1.0-SNAPSHOT
+
+
+ 11
+ 11
+ UTF-8
+
+
+
+
+
+ com.microsoft.playwright
+ playwright
+ 1.60.0
+
+
+ com.deque.axe_core
+ watcher
+ 4.4.0
+
+
+ org.junit.jupiter
+ junit-jupiter
+ 5.11.3
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.2.5
+
+
+
+
+
diff --git a/java/playwright/manual-mode/src/test/java/com/deque/watcher_examples/playwright/ManualModeTest.java b/java/playwright/manual-mode/src/test/java/com/deque/watcher_examples/playwright/ManualModeTest.java
new file mode 100644
index 0000000..6b13676
--- /dev/null
+++ b/java/playwright/manual-mode/src/test/java/com/deque/watcher_examples/playwright/ManualModeTest.java
@@ -0,0 +1,140 @@
+package com.deque.watcher_examples.playwright;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import com.deque.axe_core.commons.AxeWatcherOptions;
+import com.deque.axe_core.playwright.AxeWatcherPage;
+import com.deque.axe_core.playwright.AxeWatcherPlaywright;
+import com.microsoft.playwright.BrowserContext;
+import com.microsoft.playwright.BrowserType;
+import com.microsoft.playwright.Playwright;
+import java.nio.file.Paths;
+import java.util.Arrays;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+/*
+ Manual mode.
+
+ Setting setAutoAnalyze(false) starts axe Watcher with automatic analysis paused. You then control
+ exactly when scans happen through the controller returned by page.axeWatcher():
+
+ - analyze() — scan the current page state now, regardless of whether analysis is paused.
+ - start() — resume automatic analysis.
+ - stop() — pause automatic analysis.
+*/
+@DisplayName("My Login Application")
+class ManualModeTest {
+
+ Playwright playwright;
+ BrowserContext context;
+ AxeWatcherPage page;
+
+ @BeforeEach
+ void setUp() {
+ AxeWatcherPlaywright axeWatcher =
+ new AxeWatcherPlaywright(
+ new AxeWatcherOptions()
+ .setApiKey(System.getenv("API_KEY"))
+ .setProjectId(System.getenv("PROJECT_ID"))
+ .setServerUrl(serverUrl())
+ .setAutoAnalyze(false))
+ .enableDebugLogger();
+
+ // configure() merges the axe Watcher extension flags into the launch options. It must be
+ // called before launching the persistent context — Chromium only loads extensions when
+ // launched via a persistent context.
+ BrowserType.LaunchPersistentContextOptions launchOptions =
+ axeWatcher.configure(browserOptions());
+
+ playwright = Playwright.create();
+ // An empty path tells Playwright to use a temporary profile directory.
+ context = playwright.chromium().launchPersistentContext(Paths.get(""), launchOptions);
+
+ page = axeWatcher.wrapPage(context.newPage());
+ }
+
+ @AfterEach
+ void tearDown() {
+ // Send the collected results to axe Developer Hub after each test.
+ page.axeWatcher().flush();
+ context.close();
+ playwright.close();
+ }
+
+ @Nested
+ @DisplayName("Login")
+ class LoginTests {
+ @Nested
+ @DisplayName("with valid credentials")
+ class ShouldLoginTests {
+ @Test
+ @DisplayName("should login")
+ void shouldLoginTest() {
+ /*
+ Let's count the page states. Auto-analysis starts disabled, so nothing is
+ analyzed until we ask for it.
+
+ We navigate to the page,
+ then analyze it manually. (+1)
+ We fill out the form,
+ then resume automatic analysis.
+ We click the button,
+ triggering an automatic analysis. (+1)
+ We confirm the success element appears.
+ We pause automatic analysis,
+ then analyze the logged-in state manually. (+1)
+
+ So we expect a total of 3 page states.
+ */
+ page.navigate("https://the-internet.herokuapp.com/login");
+ page.axeWatcher().analyze();
+
+ page.locator("#username").fill("tomsmith");
+ page.locator("#password").fill("SuperSecretPassword!");
+ page.axeWatcher().start();
+
+ page.locator("button[type='submit']").click();
+
+ assertNotNull(page.waitForSelector("#flash"));
+
+ page.axeWatcher().stop();
+ page.axeWatcher().analyze();
+ }
+ }
+ }
+
+ private static String serverUrl() {
+ String serverUrl = System.getenv("SERVER_URL");
+ return serverUrl != null ? serverUrl : "https://axe.deque.com";
+ }
+
+ private static BrowserType.LaunchPersistentContextOptions browserOptions() {
+ /*
+ axe Watcher's browser extension loads only in a headed browser or Chromium's "new"
+ headless mode — never Chromium's classic/default headless mode. We run headless here
+ (with a Chromium-based channel, selected below, which new-headless requires) so the
+ example works in CI; call setHeadless(false) to watch the run in a visible window.
+ "--no-sandbox" lets Chromium run as root in CI and isn't needed for local headed runs.
+ */
+ BrowserType.LaunchPersistentContextOptions options =
+ new BrowserType.LaunchPersistentContextOptions()
+ .setHeadless(true)
+ .setArgs(Arrays.asList("--no-sandbox"));
+
+ // In CI we point Playwright at the Chrome installed on the runner (CHROME_BIN). Locally,
+ // without CHROME_BIN, Playwright falls back to its bundled Chromium — install it with
+ // `mvn exec:java -Dexec.mainClass=com.microsoft.playwright.CLI -Dexec.args="install chromium"`.
+ String chromeBin = System.getenv("CHROME_BIN");
+ if (chromeBin != null) {
+ options.setChannel("chrome").setExecutablePath(Paths.get(chromeBin));
+ } else {
+ options.setChannel("chromium");
+ }
+
+ return options;
+ }
+}
diff --git a/java/playwright/multi-page/pom.xml b/java/playwright/multi-page/pom.xml
new file mode 100644
index 0000000..634e7f0
--- /dev/null
+++ b/java/playwright/multi-page/pom.xml
@@ -0,0 +1,50 @@
+
+
+ 4.0.0
+
+ com.deque.watcher_examples.playwright
+ multi-page
+ 1.0-SNAPSHOT
+
+
+ 11
+ 11
+ UTF-8
+
+
+
+
+
+ com.microsoft.playwright
+ playwright
+ 1.60.0
+
+
+ com.deque.axe_core
+ watcher
+ 4.4.0
+
+
+ org.junit.jupiter
+ junit-jupiter
+ 5.11.3
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.2.5
+
+
+
+
+
diff --git a/java/playwright/multi-page/src/test/java/com/deque/watcher_examples/playwright/MultiPageTest.java b/java/playwright/multi-page/src/test/java/com/deque/watcher_examples/playwright/MultiPageTest.java
new file mode 100644
index 0000000..b1b46a7
--- /dev/null
+++ b/java/playwright/multi-page/src/test/java/com/deque/watcher_examples/playwright/MultiPageTest.java
@@ -0,0 +1,193 @@
+package com.deque.watcher_examples.playwright;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.deque.axe_core.commons.AxeWatcherOptions;
+import com.deque.axe_core.playwright.AxeWatcherPage;
+import com.deque.axe_core.playwright.AxeWatcherPlaywright;
+import com.microsoft.playwright.BrowserContext;
+import com.microsoft.playwright.BrowserType;
+import com.microsoft.playwright.ElementHandle;
+import com.microsoft.playwright.Playwright;
+import java.nio.file.Paths;
+import java.util.Arrays;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+/*
+ Driving one instrumented page across the pages of an application.
+
+ A single wrapped Page is launched once for the whole class and reused as the suite navigates
+ between the home, login, and forgot-password pages. axe Watcher analyzes each page automatically;
+ flushing after every test uploads the page states captured along the way. The @Nested classes
+ group the assertions by the page under test.
+*/
+@DisplayName("My Multi-page Application")
+class MultiPageTest {
+
+ static Playwright playwright;
+ static BrowserContext browserContext;
+ static AxeWatcherPage page;
+
+ @BeforeAll
+ static void setUp() {
+ AxeWatcherPlaywright axeWatcher =
+ new AxeWatcherPlaywright(
+ new AxeWatcherOptions()
+ .setApiKey(System.getenv("API_KEY"))
+ .setProjectId(System.getenv("PROJECT_ID"))
+ .setServerUrl(serverUrl()))
+ .enableDebugLogger();
+
+ // configure() merges the axe Watcher extension flags into the launch options. It must be
+ // called before launching the persistent context — Chromium only loads extensions when
+ // launched via a persistent context.
+ BrowserType.LaunchPersistentContextOptions launchOptions =
+ axeWatcher.configure(browserOptions());
+
+ playwright = Playwright.create();
+
+ // An empty path tells Playwright to use a temporary profile directory.
+ browserContext =
+ playwright.chromium().launchPersistentContext(Paths.get(""), launchOptions);
+ page = axeWatcher.wrapPage(browserContext.newPage());
+ }
+
+ @AfterAll
+ static void tearDown() {
+ browserContext.close();
+ playwright.close();
+ }
+
+ @AfterEach
+ void flush() {
+ // Results are only uploaded when you flush — do it after every test.
+ page.axeWatcher().flush();
+ }
+
+ @Nested
+ @DisplayName("Homepage")
+ class HomepageTests {
+ @BeforeEach
+ void visit() {
+ page.navigate("https://the-internet.herokuapp.com");
+ }
+
+ @Test
+ @DisplayName("should contain a list of links")
+ void shouldContainAListOfLinksTest() {
+ // count() does not auto-wait, so wait for the link list to render before counting.
+ page.locator("ul li a").first().waitFor();
+ assertTrue(page.locator("ul li a").count() >= 20);
+ }
+
+ @Test
+ @DisplayName("should contain a link to the login page")
+ void shouldContainALinkToTheLoginPageTest() {
+ ElementHandle loginLink = page.querySelector("ul li a[href='/login']");
+ assertNotNull(loginLink);
+ }
+ }
+
+ @Nested
+ @DisplayName("Login page")
+ class LoginPageTests {
+ @BeforeEach
+ void visit() {
+ page.navigate("https://the-internet.herokuapp.com/login");
+ }
+
+ @Test
+ @DisplayName("should contain a username input")
+ void shouldContainAUsernameInputTest() {
+ ElementHandle usernameInput = page.querySelector("#username");
+ assertNotNull(usernameInput);
+ }
+
+ @Test
+ @DisplayName("should contain a password input")
+ void shouldContainAPasswordInputTest() {
+ ElementHandle passwordInput = page.querySelector("#password");
+ assertNotNull(passwordInput);
+ }
+
+ @Test
+ @DisplayName("should contain a submit button")
+ void shouldContainASubmitButtonTest() {
+ ElementHandle submitButton = page.querySelector("button[type='submit']");
+ assertNotNull(submitButton);
+ }
+
+ @Test
+ @DisplayName("entering credentials and submitting the form should login")
+ void shouldLoginTest() {
+ page.locator("#username").fill("tomsmith");
+ page.locator("#password").fill("SuperSecretPassword!");
+
+ page.locator("button[type='submit']").click();
+
+ ElementHandle element = page.waitForSelector("#flash");
+ assertNotNull(element);
+ }
+ }
+
+ @Nested
+ @DisplayName("Forgot password page")
+ class ForgotPasswordTests {
+ @BeforeEach
+ void visit() {
+ page.navigate("https://the-internet.herokuapp.com/forgot_password");
+ }
+
+ @Test
+ @DisplayName("should contain an email input")
+ void shouldContainAnEmailInputTest() {
+ ElementHandle input = page.querySelector("#email");
+ assertNotNull(input);
+ }
+
+ @Test
+ @DisplayName("should contain a submit button")
+ void shouldContainASubmitButtonTest() {
+ ElementHandle button = page.querySelector("button[type='submit']");
+ assertNotNull(button);
+ }
+ }
+
+ private static String serverUrl() {
+ String serverUrl = System.getenv("SERVER_URL");
+ return serverUrl != null ? serverUrl : "https://axe.deque.com";
+ }
+
+ private static BrowserType.LaunchPersistentContextOptions browserOptions() {
+ /*
+ axe Watcher's browser extension loads only in a headed browser or Chromium's "new"
+ headless mode — never Chromium's classic/default headless mode. We run headless here
+ (with a Chromium-based channel, selected below, which new-headless requires) so the
+ example works in CI; call setHeadless(false) to watch the run in a visible window.
+ "--no-sandbox" lets Chromium run as root in CI and isn't needed for local headed runs.
+ */
+ BrowserType.LaunchPersistentContextOptions options =
+ new BrowserType.LaunchPersistentContextOptions()
+ .setHeadless(true)
+ .setArgs(Arrays.asList("--no-sandbox"));
+
+ // In CI we point Playwright at the Chrome installed on the runner (CHROME_BIN). Locally,
+ // without CHROME_BIN, Playwright falls back to its bundled Chromium — install it with
+ // `mvn exec:java -Dexec.mainClass=com.microsoft.playwright.CLI -Dexec.args="install chromium"`.
+ String chromeBin = System.getenv("CHROME_BIN");
+ if (chromeBin != null) {
+ options.setChannel("chrome").setExecutablePath(Paths.get(chromeBin));
+ } else {
+ options.setChannel("chromium");
+ }
+
+ return options;
+ }
+}