Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions build-logic/src/main/kotlin/pkauth.e2e-conventions.gradle.kts
Original file line number Diff line number Diff line change
@@ -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/<demo>/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<BuildServiceParameters.None>

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<Exec>("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:<demo>:e2eTest` still runs it on demand.
if (runE2e) {
tasks.named("check") {
dependsOn(e2eTest)
}
}
1 change: 1 addition & 0 deletions examples/dropwizard-demo/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
plugins {
id("pkauth.java-conventions")
id("pkauth.test-conventions")
id("pkauth.e2e-conventions")
application
}

Expand Down
4 changes: 3 additions & 1 deletion examples/dropwizard-demo/e2e/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions examples/micronaut-demo/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
plugins {
application
id("pkauth.java-conventions")
id("pkauth.e2e-conventions")
}

description = "pk-auth Micronaut demo app."
Expand Down Expand Up @@ -62,6 +63,12 @@ tasks.named<JavaExec>("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>("test") {
Expand Down
4 changes: 3 additions & 1 deletion examples/micronaut-demo/e2e/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions examples/spring-boot-demo/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
plugins {
id("pkauth.java-conventions")
id("pkauth.test-conventions")
id("pkauth.e2e-conventions")
application
}

Expand Down
4 changes: 3 additions & 1 deletion examples/spring-boot-demo/e2e/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading