From a6fa76c4051f0a2696af1991c34371b878e6ee3d Mon Sep 17 00:00:00 2001 From: Ned Wolpert Date: Sat, 13 Jun 2026 21:22:50 -0700 Subject: [PATCH] ci(examples): wire Playwright e2e suites into check and CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three demos' Playwright e2e suites (Chrome CDP virtual WebAuthn) were never run by Gradle or CI. Wire them in via a new `pkauth.e2e-conventions` plugin that registers an opt-in `e2eTest` task per demo: - Gated on PK_RUN_E2E=1 (or -PrunE2e); only then does `check` depend on it, so the default local `check` and the existing CI `build` job stay fast and Chrome-free. Resolved to a plain Boolean at configuration time so the wiring is configuration-cache-safe. - A shared BuildService lock (maxParallelUsages=1) serializes the suites across a single build, since every demo's webServer binds :8080 and org.gradle.parallel=true would otherwise let them collide. - Adds a CI `e2e` matrix job (one isolated runner per adapter, fail-fast false) that flips the flag and installs Chrome via PW_INSTALL_DEPS. Also fixes two pre-existing defects the suites surfaced once they actually ran: - playwright.config.ts (all 3): the webServer ran `../../../gradlew` from the e2e/ dir, so Gradle took e2e/ as the project dir and failed. Pass `-p ../../..` to root Gradle at the repo. - micronaut-demo: dev-mode was set only in application.yml, which (like the relying-party keys) does not reach bean conditions reliably — so the @Requires(pkauth.dev-mode=true) LoggingEmailSender/LoggingSmsSender beans never activated and the unconditional magicLinkService/otpService singletons failed to inject EmailSender/SmsSender (500 at request time). Pass -Dpkauth.dev-mode=true as a system property in the run task. All three suites now pass 5/5. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 47 ++++++++++++++ CLAUDE.md | 2 +- .../kotlin/pkauth.e2e-conventions.gradle.kts | 61 +++++++++++++++++++ examples/dropwizard-demo/build.gradle.kts | 1 + .../dropwizard-demo/e2e/playwright.config.ts | 4 +- examples/micronaut-demo/build.gradle.kts | 7 +++ .../micronaut-demo/e2e/playwright.config.ts | 4 +- examples/spring-boot-demo/build.gradle.kts | 1 + .../spring-boot-demo/e2e/playwright.config.ts | 4 +- 9 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 build-logic/src/main/kotlin/pkauth.e2e-conventions.gradle.kts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index edc6d7e..8f3719a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,6 +130,53 @@ jobs: path: clients/passkeys-browser/reports/mutation/ if-no-files-found: ignore + # Playwright end-to-end suites for the three example demos. Each demo carries a sibling `e2e/` + # Playwright project that drives the full passkey ceremony through Chrome's CDP virtual WebAuthn + # authenticator. These are wired into Gradle `check` as an OPT-IN task (skipped by default so the + # `build` job above stays fast and Chrome-free) — here we flip the switch with PK_RUN_E2E=1 and + # let the `e2eTest` task provision Chrome (PW_INSTALL_DEPS=1 → playwright install --with-deps) and + # boot the demo via its Gradle `run` task. One job per adapter (all three bind :8080). + e2e: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + demo: [ spring-boot-demo, dropwizard-demo, micronaut-demo ] + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + + - name: Set up JDK 21 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + with: + java-version: '21' + distribution: 'temurin' + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '20' + + # The e2eTest task executes the wrapper jar (directly and again nested, when Playwright boots + # the demo via the Gradle `run` task) — validate it for the same reason the build job does. + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # v6 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6 + + - name: Run ${{ matrix.demo }} e2e + run: ./gradlew :examples:${{ matrix.demo }}:e2eTest --stacktrace + env: + PK_RUN_E2E: '1' # flip the opt-in e2eTest task on + PW_INSTALL_DEPS: '1' # playwright install --with-deps chrome (needs the runner's apt deps) + + - name: Upload Playwright traces + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: playwright-traces-${{ matrix.demo }} + path: examples/${{ matrix.demo }}/e2e/test-results/ + if-no-files-found: ignore + # Block PRs that introduce dependencies with known vulnerabilities or incompatible # licenses, before they merge. Uses the GitHub dependency graph; PR-only. dependency-review: diff --git a/CLAUDE.md b/CLAUDE.md index 7ee225c..f41ce6a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ JDK 21 is required (Gradle's toolchain fetches one if absent). Node ≥ 20 + npm ``` - **Integration tests are ordinary `test` tasks** — the JDBI/DynamoDB persistence modules use Testcontainers (Postgres / DynamoDB Local), so `./gradlew :pk-auth-persistence-jdbi:test` needs **Docker** running. There is no separate `integrationTest` task or JUnit tag. -- Playwright end-to-end suites live under the `examples/` demos and need **Chrome** (drives a CDP virtual WebAuthn authenticator). +- Playwright end-to-end suites live under the `examples/` demos and need **Chrome** (drives a CDP virtual WebAuthn authenticator). They are wired into each demo's `check` via the `e2eTest` task (`pkauth.e2e-conventions`) but are **opt-in** — they only run when `PK_RUN_E2E=1` (or `-PrunE2e`) is set, so a default `./gradlew check` and the CI `build` job stay fast and Chrome-free. Run one with `PK_RUN_E2E=1 ./gradlew :examples:spring-boot-demo:e2eTest`; the task itself does `npm ci` + `npx playwright install chrome` and boots the demo via its Gradle `run` task. CI runs all three in the `e2e` matrix job (`.github/workflows/ci.yml`, with `PW_INSTALL_DEPS=1` so Chrome's OS deps are installed too). - The Gradle **build cache is disabled** on purpose (Spotless 8.x classloader bug) — see the comment in `gradle.properties`. Don't re-enable it. ## Architecture: three concentric rings diff --git a/build-logic/src/main/kotlin/pkauth.e2e-conventions.gradle.kts b/build-logic/src/main/kotlin/pkauth.e2e-conventions.gradle.kts new file mode 100644 index 0000000..5f662e0 --- /dev/null +++ b/build-logic/src/main/kotlin/pkauth.e2e-conventions.gradle.kts @@ -0,0 +1,61 @@ +// E2E (Playwright) conventions for the example demos. Each demo carries a sibling `e2e/` +// Playwright project that drives the full passkey ceremony through Chrome's CDP virtual WebAuthn +// authenticator (see examples//e2e/). This wires that suite into `check` as an OPT-IN task: +// it runs only when PK_RUN_E2E=1 (or -PrunE2e) is set, so the default local `check` and the +// existing CI `build` job stay fast and Chrome-free. The dedicated CI e2e jobs +// (.github/workflows/ci.yml) set the flag; the task itself provisions the Chrome channel via +// `npx playwright install` (adding --with-deps when PW_INSTALL_DEPS is set, as CI does). +plugins { + java +} + +// Every demo's webServer binds the same port (:8080), so two e2eTest tasks must never run at once. +// With org.gradle.parallel=true a root-level `PK_RUN_E2E=1 ./gradlew check` would otherwise run the +// sibling demos' suites concurrently and collide on the port. A shared build service with +// maxParallelUsages=1 serializes them across the whole build (registerIfAbsent dedupes by name, so +// all three demos share one lock). In CI each demo runs on its own isolated runner, so this lock is +// a no-op there — it only matters for single-machine multi-demo runs. +abstract class E2eServerLock : BuildService + +val e2eServerLock = gradle.sharedServices.registerIfAbsent( + "pkauthE2eServerLock", + E2eServerLock::class, +) { + maxParallelUsages.set(1) +} + +// Opt-in switch: PK_RUN_E2E=1 in the environment, or -PrunE2e on the command line. An explicitly +// set PK_RUN_E2E (even to a non-"1" value) wins over the property, so `PK_RUN_E2E=0` forces off. +// Resolved to a plain Boolean at configuration time (the env var / property become configuration +// cache inputs) so the wiring below captures no script reference — an `onlyIf { provider.get() }` +// closure would not be configuration-cache-serializable. +val runE2e: Boolean = providers.environmentVariable("PK_RUN_E2E").orNull + ?.let { it == "1" } + ?: providers.gradleProperty("runE2e").isPresent + +val e2eDir = layout.projectDirectory.dir("e2e") + +val e2eTest = tasks.register("e2eTest") { + group = "verification" + description = "Runs the Playwright end-to-end suite for this demo (opt-in: set PK_RUN_E2E=1)." + usesService(e2eServerLock) // serialize across demos — they share port :8080 + workingDir = e2eDir.asFile + // `npm ci` pins deps to the committed lockfile. `playwright install` provisions the Chrome + // channel the config pins (CDP virtual WebAuthn needs Google Chrome); --with-deps is added in + // CI (PW_INSTALL_DEPS) to also install the OS libraries. The webServer in playwright.config.ts + // boots this demo via the Gradle `run` task, so no separately-started server is needed. + commandLine( + "sh", "-c", + "npm ci --no-audit --no-fund && " + + "npx playwright install ${'$'}{PW_INSTALL_DEPS:+--with-deps} chrome && " + + "npx playwright test", + ) +} + +// Wire into `check` only when opted in, so a default `check` (local or the CI `build` job) neither +// depends on nor runs the suite. `./gradlew :examples::e2eTest` still runs it on demand. +if (runE2e) { + tasks.named("check") { + dependsOn(e2eTest) + } +} diff --git a/examples/dropwizard-demo/build.gradle.kts b/examples/dropwizard-demo/build.gradle.kts index 3589440..1d68d1c 100644 --- a/examples/dropwizard-demo/build.gradle.kts +++ b/examples/dropwizard-demo/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("pkauth.java-conventions") id("pkauth.test-conventions") + id("pkauth.e2e-conventions") application } diff --git a/examples/dropwizard-demo/e2e/playwright.config.ts b/examples/dropwizard-demo/e2e/playwright.config.ts index 9f0c638..e395742 100644 --- a/examples/dropwizard-demo/e2e/playwright.config.ts +++ b/examples/dropwizard-demo/e2e/playwright.config.ts @@ -27,7 +27,9 @@ export default defineConfig({ : { // Dropwizard's run task starts with `server` arg; the persistence flavor is read from // the `pkauth.persistence` system property (see DemoApplication#persistenceFlavor). - command: `../../../gradlew :examples:dropwizard-demo:run -Dpkauth.persistence=${PERSISTENCE}`, + // `-p ../../..` roots Gradle at the repo (the webServer runs from this config's dir, so a + // bare `../../../gradlew` would otherwise take the e2e/ dir as the project dir and fail). + command: `../../../gradlew -p ../../.. :examples:dropwizard-demo:run -Dpkauth.persistence=${PERSISTENCE}`, url: BASE_URL, reuseExistingServer: !process.env.CI, timeout: 180_000, diff --git a/examples/micronaut-demo/build.gradle.kts b/examples/micronaut-demo/build.gradle.kts index df015cc..c96f26c 100644 --- a/examples/micronaut-demo/build.gradle.kts +++ b/examples/micronaut-demo/build.gradle.kts @@ -1,6 +1,7 @@ plugins { application id("pkauth.java-conventions") + id("pkauth.e2e-conventions") } description = "pk-auth Micronaut demo app." @@ -62,6 +63,12 @@ tasks.named("run") { systemProperty("pkauth.jwt.issuer", "pk-auth-micronaut-demo") systemProperty("pkauth.jwt.audience", "pk-auth-micronaut-demo-clients") systemProperty("pkauth.jwt.secret", "change-me-in-prod-use-a-32-byte-secret!!") + // dev-mode is also set in application.yml, but — like the relying-party keys above — it must be + // passed as a system property to reliably reach the bean conditions: the LoggingEmailSender / + // LoggingSmsSender beans in PkAuthFactory are @Requires(property = "pkauth.dev-mode", value = + // "true"), and without them the unconditional magicLinkService / otpService singletons fail to + // inject EmailSender / SmsSender at request time (a 500 the e2e suite exercises). + systemProperty("pkauth.dev-mode", "true") } tasks.named("test") { diff --git a/examples/micronaut-demo/e2e/playwright.config.ts b/examples/micronaut-demo/e2e/playwright.config.ts index 21a13c9..a8230c5 100644 --- a/examples/micronaut-demo/e2e/playwright.config.ts +++ b/examples/micronaut-demo/e2e/playwright.config.ts @@ -25,7 +25,9 @@ export default defineConfig({ webServer: externallyManaged ? undefined : { - command: `../../../gradlew :examples:micronaut-demo:run -Dpkauth.persistence=${PERSISTENCE}`, + // `-p ../../..` roots Gradle at the repo (the webServer runs from this config's dir, so a + // bare `../../../gradlew` would otherwise take the e2e/ dir as the project dir and fail). + command: `../../../gradlew -p ../../.. :examples:micronaut-demo:run -Dpkauth.persistence=${PERSISTENCE}`, url: BASE_URL, reuseExistingServer: !process.env.CI, timeout: 180_000, diff --git a/examples/spring-boot-demo/build.gradle.kts b/examples/spring-boot-demo/build.gradle.kts index 2babbc1..d87b1e1 100644 --- a/examples/spring-boot-demo/build.gradle.kts +++ b/examples/spring-boot-demo/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("pkauth.java-conventions") id("pkauth.test-conventions") + id("pkauth.e2e-conventions") application } diff --git a/examples/spring-boot-demo/e2e/playwright.config.ts b/examples/spring-boot-demo/e2e/playwright.config.ts index 56342c3..737417d 100644 --- a/examples/spring-boot-demo/e2e/playwright.config.ts +++ b/examples/spring-boot-demo/e2e/playwright.config.ts @@ -29,7 +29,9 @@ export default defineConfig({ webServer: externallyManaged ? undefined : { - command: `../../../gradlew :examples:spring-boot-demo:run --args='--demo.persistence=${PERSISTENCE}'`, + // `-p ../../..` roots Gradle at the repo (the webServer runs from this config's dir, so a + // bare `../../../gradlew` would otherwise take the e2e/ dir as the project dir and fail). + command: `../../../gradlew -p ../../.. :examples:spring-boot-demo:run --args='--demo.persistence=${PERSISTENCE}'`, url: BASE_URL, reuseExistingServer: !process.env.CI, timeout: 180_000,