AP-638: Implements e2e tests using Playwright#34
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a Playwright-based end-to-end (E2E) auth test suite for the Airflow UI (via Keycloak OIDC), plus Docker/CI wiring to run the browser remotely in a sidecar container.
Changes:
- Introduce
test/e2e/Playwright tests + helpers for the Airflow→Keycloak login flow. - Add a Playwright sidecar service (Dockerfile + entrypoint) and enable it under a Compose
testprofile. - Update pytest configuration/dependencies and CI to start the stack with the
testprofile and retain Playwright artifacts on failures.
Reviewed changes
Copilot reviewed 9 out of 11 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
test/e2e/test_auth.py |
New E2E tests asserting login is required and role-based access to homepage/admin pages. |
test/e2e/helpers.py |
Helper to drive the Airflow→Keycloak OIDC login flow in Playwright. |
test/e2e/conftest.py |
Pytest fixtures to connect to a remote Playwright server and set base_url. |
test/e2e/__init__.py |
Package marker for the E2E tests. |
requirements.txt |
Adds Playwright + pytest-playwright dependency pins/hashes. |
pyproject.toml |
Adds pytest-playwright to test extras; configures pytest Playwright artifact options and marker. |
playwright/entrypoint.sh |
Starts TCP forwarders (socat) for localhost port parity inside the sidecar container. |
playwright/Dockerfile |
Builds the Playwright sidecar image (extends MS Playwright image + installs socat). |
example.env |
Documents E2E env vars for running tests via the Playwright sidecar. |
docker-compose.yml |
Adds a playwright service under the test profile to run the remote Playwright server. |
.github/workflows/build.yml |
Starts Compose with --profile test ... --wait so the sidecar is available in CI. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| page.locator("#username").fill(username) | ||
| page.locator("#password").fill(password) | ||
| page.locator("#kc-login").click() | ||
| page.wait_for_url(lambda url: not KEYCLOAK_AUTH_URL_RE.search(url)) |
There was a problem hiding this comment.
page.wait_for_url(lambda url: not KEYCLOAK_AUTH_URL_RE.search(url)) can return as soon as navigation leaves /protocol/openid-connect/auth, even if the browser is still on a different Keycloak URL (e.g., login-actions endpoints), which can make the helper return before the post-login redirect back to Airflow completes. Consider waiting until the URL is no longer on the Keycloak base host/port (or matches the Airflow base URL), and/or broadening the Keycloak regex to cover all realm paths so the wait cannot finish while still in Keycloak.
| expect(page.locator(ADMIN_USERS_TABLE_HEADER)).to_be_visible() | ||
| else: | ||
| expect(page.locator(ADMIN_USERS_TABLE_HEADER)).not_to_be_visible() |
There was a problem hiding this comment.
In the denied case, this test only asserts that the admin users table header is not visible. That can pass for many unintended outcomes (redirect to an error page, a blank page, a 500, etc.). To make the authorization check meaningful and reduce false positives/flakiness, assert the expected denied behavior as well (e.g., URL is the login chooser, or an explicit 403/"Forbidden" marker is shown).
| # wire protocol is not stable across versions. | ||
| playwright: | ||
| build: ./playwright | ||
| command: ["npx", "-y", "playwright@1.58.0", "run-server", "--port", "3000", "--host", "0.0.0.0", "--path", "/ws"] |
There was a problem hiding this comment.
The Playwright service hard-codes playwright@1.58.0 in the npx command, duplicating the version already pinned via ARG PLAYWRIGHT_VERSION in playwright/Dockerfile. This can drift and also tends to force an npx download on container start. Prefer invoking the Playwright CLI already present in the base image (e.g., playwright run-server ...) and/or eliminate the duplicated version so a single source of truth controls the server/client protocol version.
| command: ["npx", "-y", "playwright@1.58.0", "run-server", "--port", "3000", "--host", "0.0.0.0", "--path", "/ws"] | |
| command: ["playwright", "run-server", "--port", "3000", "--host", "0.0.0.0", "--path", "/ws"] |
| - name: Start the stack | ||
| run: | | ||
| docker compose up --detach | ||
| docker compose --profile test up --detach --wait |
There was a problem hiding this comment.
Playwright is pretty lightweight, so I'm not sure we need to gate this behind a profile.
awilfox
left a comment
There was a problem hiding this comment.
Some discussion items, but overall a good direction.
| page.locator("#username").fill(username) | ||
| page.locator("#password").fill(password) | ||
| page.locator("#kc-login").click() | ||
| page.wait_for_url(lambda url: not KEYCLOAK_AUTH_URL_RE.search(url)) |
There was a problem hiding this comment.
I'm in agreement with Copilot and feel like this is an excellent use of KEYCLOAK_BASE_URL.
| page.wait_for_url(lambda url: not KEYCLOAK_AUTH_URL_RE.search(url)) | |
| page.wait_for_url(lambda url: not url.startswith(KEYCLOAK_BASE_URL)) |
| @@ -0,0 +1,23 @@ | |||
| import re | |||
| KEYCLOAK_AUTH_URL_RE = re.compile(r"/realms/berkeley-local/protocol/openid-connect/auth") | ||
|
|
||
|
|
||
| def login(page: Page, username: str, password: str) -> None: |
There was a problem hiding this comment.
I feel like it would be good to have a decorator for admin and user. Perhaps public too, though that shouldn't be generally useful.
This way, tests could have @as_admin or @as_user later on. Otherwise, there will be a lot of code duplication, and hardcoding the test credentials which we may not want to do.
| if allowed: | ||
| expect(page.locator(ADMIN_USERS_TABLE_HEADER)).to_be_visible() | ||
| else: | ||
| expect(page.locator(ADMIN_USERS_TABLE_HEADER)).not_to_be_visible() |
There was a problem hiding this comment.
Agree with Copilot, this should be explicitly testing for a 403, not just the lack of presence of a table.
There was a problem hiding this comment.
Unfortunately AirFlow makes this much, much harder than it needs to be. A public user is actually redirected to the login page; a regular non-admin user gets an HTTP 200 with a page that says "404"… I'll see what I can do to clean it up but so long as we're actually browsing like a human user the assertion will be messy.
|
@jason-raitz @awilfox Re-requesting review after the following major changes:
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 9 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| #PYTEST_PLAYWRIGHT_WS_ENDPOINT= | ||
| # Set these to run against non-localhost airflow or keycloak instances. | ||
| #PYTEST_AIRFLOW_BASE_URL= | ||
| #PYTEST_KEYCLOAK_BASE_URL |
There was a problem hiding this comment.
#PYTEST_KEYCLOAK_BASE_URL is missing the trailing = (unlike the other commented env vars). As-is, it can’t be uncommented/edited consistently and may confuse copy/paste into a real .env.
| #PYTEST_KEYCLOAK_BASE_URL | |
| #PYTEST_KEYCLOAK_BASE_URL= |
|
|
||
| import pytest | ||
|
|
||
| from .conftest import airflow_login_url, airflow_url, login |
There was a problem hiding this comment.
login is imported from .conftest but never used in this test module. This will trip unused-import checks (e.g., pylint) and adds noise; remove the unused import or use it directly in a test.
| from .conftest import airflow_login_url, airflow_url, login | |
| from .conftest import airflow_login_url, airflow_url |
awilfox
left a comment
There was a problem hiding this comment.
Style issues, but structurally sound.
|
|
||
|
|
||
| def login(page: Page, username: str, password: str) -> None: | ||
| """Walk a fresh page through the Airflow -> Keycloak OIDC login flow. |
There was a problem hiding this comment.
| """Walk a fresh page through the Airflow -> Keycloak OIDC login flow. | |
| """Walk a fresh instance through the Airflow -> Keycloak OIDC login flow. |
?
| """Walk a fresh page through the Airflow -> Keycloak OIDC login flow. | |
| """Walk a fresh browser through the Airflow -> Keycloak OIDC login flow. |
?
"page" doesn't seem right here.
There was a problem hiding this comment.
In Playwright terms it's a Page, meaning a fresh tab in a per-test isolated browser. That last part is not obvious (I originally thought pages shared a Browser context); see https://playwright.dev/python/docs/browser-contexts.
| expect(page.locator("#btn-signin-keycloak")).to_be_visible() | ||
|
|
||
|
|
||
| def test_public_user_cannot_view_homepage(as_public_user) -> None: |
There was a problem hiding this comment.
I was thinking of a decorator when I made that example name. With the literate RSpec-like expect, something like public_browser makes more sense to me.
| def test_public_user_cannot_view_homepage(as_public_user) -> None: | |
| def test_public_user_cannot_view_homepage(public_browser) -> None: |
There was a problem hiding this comment.
I considered this, but Browser has a specific meaning and this is really a Page object. Agree that "as_{user}" doesn't feel perfect, but it does encapsulate the credentials used which is my only real goal for now.
I'm accustomed to using fixtures to inject behavior into pytests — what did you have in mind with a raw decorator?
anarchivist
left a comment
There was a problem hiding this comment.
rw+c; mine are minor, @awilfox's probably merit more discussion.
- Implements end-to-end (e2e) browser-based tests using Playwright. - Resolves issues running the stack as a human developer vs. tests from the container by using `socat` in the playwright container to forward localhost:<port> to the respective service. - Adds an initial set of tests for the OIDC login flow (admin, user, public, non-auth'd).
Implements end-to-end browser-based testing using Playwright w/Chromium. The initial tests cover the OIDC login flow, ensuring that:
Playwright runs in a new
playwrightservice in the Compose File, executed over-the-wire by pytest running from AirFlow. This introduces some complications around reconciling AirFlow/Keycloak URLs when accessing as a developer (on localhost) vs. as Playwright in the stack. See Slack for a rundown on the approaches I considered. I ultimately settled on extending the Playwright image to includesocat, and using that for forward localhost:8080 -> airflow and localhost:8180 -> keycloak within theplaywrightservice.