Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
1d4f21e
Add apps/e2e Playwright package scaffold
batzionb Jul 27, 2026
fc4d3cb
Add automated Keycloak login for e2e auth setup
batzionb Jul 27, 2026
6f7d768
Add authenticated smoke test
batzionb Jul 27, 2026
5731f15
Ignore e2e auth state and test output
batzionb Jul 27, 2026
cd50327
Add apps/e2e README
batzionb Jul 27, 2026
ee45061
Fix e2e auth against a real cluster: TLS trust and two-step login
batzionb Jul 27, 2026
c177219
Add e2e:dev script for testing against the local dev server
batzionb Jul 27, 2026
525c1b4
Stop persisting e2e test specs — harness only, tests are throwaway
batzionb Jul 27, 2026
4d92062
Clarify e2e harness docs: throwaway specs, not UI CI coverage
batzionb Jul 27, 2026
181a270
fix: address CodeRabbit review feedback on e2e harness
batzionb Jul 27, 2026
2fbc0a3
Rename e2e Playwright harness to apps/playwright to avoid confusion w…
batzionb Jul 28, 2026
422e6fc
Simplify apps/playwright wording in AGENTS.md
batzionb Jul 28, 2026
79ef782
fix: address round 2 of CodeRabbit review feedback on Playwright harness
batzionb Jul 29, 2026
89e1807
fix: add smoke spec and drop redundant playwright:dev script
batzionb Aug 2, 2026
9961980
fix: address round 3 of CodeRabbit review feedback on Playwright harness
batzionb Aug 2, 2026
58878e0
fix: remove remaining scratch-spec cleanup contradiction in README
batzionb Aug 2, 2026
1241214
docs: drop osac-test-infra reference from playwright README
batzionb Aug 2, 2026
51a2aad
fix: harden .auth directory before storageState writes session cookie
batzionb Aug 2, 2026
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
16 changes: 16 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,19 @@ proxy/osac-proxy
# AI workflow artifacts
.artifacts/
/.claude/

# apps/playwright — manual browser verification against a live cluster, not
# an e2e test suite. Live session state and run output, never committed.
apps/playwright/.auth/
apps/playwright/test-results/
apps/playwright/playwright-report/
apps/playwright/blob-report/

# apps/playwright — ad hoc specs are written here for manual verification and
# are never committed; only the harness itself is.
apps/playwright/scratch/

# apps/playwright — ad hoc specs sometimes call page.screenshot() with a bare
# filename, which lands in apps/playwright/ instead of a gitignored output dir.
apps/playwright/*.png
apps/playwright/*.jpg
28 changes: 27 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ Multi-stage build images: `nodejs-22-minimal:9.8`, `go-toolset:1.25`, `ubi-minim
| `@osac/ui-components` | Shared components consumed at source (no build) — typed gRPC hooks live here |
| `@osac/types` | Generated protobuf types and service descriptors — **never edit**, regenerate with `pnpm gen-types` |
| `@osac/i18n` | Translation extraction — `locales/en/translation.json` is generated, not hand-edited |
| `@osac/playwright` | Playwright harness for manual verification against a **live deployed cluster** — not a CI suite, not persisted test coverage. See [Manual verification against a live cluster](#manual-verification-against-a-live-cluster) |

## Code Style

Expand Down Expand Up @@ -179,9 +180,34 @@ export const getLabels = (t: TFunction) => ({
- `apps/app-frontend/vitest.config.ts` — single runner for app-frontend and ui-components tests (`include` spans both packages)
- ESLint relaxes type safety rules for test files (no-unsafe-* off)
- Testing libraries: @testing-library/react 16.x, @testing-library/jest-dom 6.x
- No E2E tests in this repoE2E coverage lives in `osac-test-infra`
- No persisted E2E test suite in this repo, and no persisted UI-level E2E coverage anywhere: `osac-test-infra`'s CI-tracked E2E suites hit the fulfillment gRPC/REST API and Kubernetes CRs directly — no browser, nothing UI-related
- CI runs lint, test, and container build; run `pnpm test` locally before submitting

### Manual verification against a live cluster

`apps/playwright` (`@osac/playwright`) is a Playwright harness for manually confirming
that a change works end-to-end against a **live, already-deployed** cluster —
not a CI suite and not something that adds to this repo's persisted test count. See
[apps/playwright/README.md](apps/playwright/README.md).

- **This is not a test suite — specs are throwaway**: write ad hoc specs under
`apps/playwright/scratch/`, a gitignored directory that only exists locally.
`pnpm playwright` runs every spec under it. Nothing there is ever committed
or needs manual cleanup — only the harness itself (`playwright.config.ts`,
`auth.setup.ts`) plus one fixed exception, `src/smoke.spec.ts` (a persisted
harness self-check — loads the app, asserts the masthead renders — not a
feature test), is committed. Never write other specs under
`apps/playwright/src/`, and never treat a `scratch/` spec as regression
coverage.
- Iterating on a `scratch/` spec: `pnpm playwright:setup` logs in once, then
`pnpm playwright:run` re-runs just the `scratch/` specs without
re-authenticating — faster than `pnpm playwright`, which always re-logs in.
- Use this when asked to verify a UI change actually works in a browser against
a real deployment — requires `OSAC_UI_BASE_URL`, `OSAC_USERNAME`, `OSAC_PASSWORD`
(or `OSAC_UI_BASE_URL=http://localhost:5173` against a local `pnpm dev`
instance). There is no mock `fulfillment-service`, so this cannot run
hermetically or in CI.

## Build

**Frontend build** (apps/app-frontend):
Expand Down
174 changes: 174 additions & 0 deletions apps/playwright/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
# @osac/playwright

Playwright browser tests for manually verifying `osac-ui` against a **live,
already-deployed** cluster — the running proxy, SPA, Keycloak realm, and
`fulfillment-service` backend all in one real environment.

This is not a CI suite. There is no mock server for `fulfillment-service`
and no way to run this hermetically — it exists for AI agents to manually confirm a
change actually works end-to-end against a real deployment, the way you'd
otherwise do by hand in a browser.

## Prerequisites

- A reachable `osac-ui` deployment (any HTTPS URL that serves it).
- A Keycloak user on that deployment's realm to log in as. This package
doesn't provision one — use an existing test account, or create one on the
target environment first.
- Playwright's browser binaries installed once per machine (see Setup below).

## Setup

```bash
pnpm install
pnpm --filter @osac/playwright exec playwright install chromium
```

## Running

Required environment variables:

| Variable | Description |
| ------------------ | --------------------------------------------------------------------------------------------------- |
| `OSAC_UI_BASE_URL` | Full URL of the `osac-ui` deployment to test, e.g. `https://osac-ui-<namespace>.<cluster-domain>` |
| `OSAC_USERNAME` | Username of a Keycloak test user on that deployment's realm |
| `OSAC_PASSWORD` | Password for that user |

```bash
export OSAC_UI_BASE_URL=https://... OSAC_USERNAME=...
read -rs OSAC_PASSWORD && export OSAC_PASSWORD # avoids leaving the password in shell history
pnpm playwright
```

(`pnpm playwright` is a root-level shortcut for `pnpm --filter @osac/playwright
run playwright`.) None of these have defaults — each is required, so a missing
value fails immediately with a clear error instead of silently pointing at the
wrong environment. `pnpm playwright` logs in, runs the [smoke
test](#smoke-test), and runs every spec under `scratch/` — see [Writing a
test](#writing-a-test).

For iterating on a `scratch/` spec, logging in on every run is slow and adds a
real Keycloak request each time. Log in once, then re-run just the spec as
many times as you need, reusing the saved session:

```bash
pnpm playwright:setup # logs in once, writes .auth/user.json
pnpm playwright:run # runs scratch/ specs only, no login
pnpm playwright:run # ...repeat as many times as you're iterating
```

`pnpm playwright:run` never re-authenticates — if `.auth/user.json` is missing
or the session has expired, re-run `pnpm playwright:setup` first.

Optional:

| Variable | Description |
| -------------------------- | ----------------------------------------------------------------------------------------------------- |
| `IGNORE_HTTPS_ERRORS` | Set to `true` to trust self-signed/cluster-internal CA certs, common on dev and lab clusters. Off by default because this flow submits a real Keycloak password — it disables TLS verification for the whole browser context, not just `localhost`, so only enable it against clusters you trust. |

Testing against a local `pnpm dev` instance instead of a remote deployment:
requires `pnpm dev` running in another terminal, and still needs
`OSAC_USERNAME`/`OSAC_PASSWORD`.

```bash
export OSAC_UI_BASE_URL=http://localhost:5173 OSAC_USERNAME=...
read -rs OSAC_PASSWORD && export OSAC_PASSWORD
pnpm playwright
```

The backing `fulfillment-service`/Keycloak for a local dev server is often a
dev/lab cluster with a self-signed or cluster-internal CA cert — the real
login redirect goes there even though the UI itself is on `localhost`, so if
you hit a TLS error, explicitly opt in with `IGNORE_HTTPS_ERRORS=true` rather
than trusting it by default:

```bash
export OSAC_UI_BASE_URL=http://localhost:5173 OSAC_USERNAME=... IGNORE_HTTPS_ERRORS=true
read -rs OSAC_PASSWORD && export OSAC_PASSWORD
pnpm playwright
```

## How authentication works

`osac-ui` has no test-mode auth bypass — every login goes through a real
Keycloak realm via the app's normal OIDC flow. Rather than repeating that
flow (and Keycloak's own login page) for every test, this harness follows
Playwright's standard pattern for testing authenticated apps:

1. A `setup` project (`src/auth.setup.ts`) opens the app, which auto-redirects
to Keycloak when unauthenticated, fills in `OSAC_USERNAME`/`OSAC_PASSWORD`
on Keycloak's real login form, and saves the resulting browser session
(cookies) to `.auth/user.json`.
2. Every real test (the `smoke` and `chromium` projects) declares a dependency
on `setup` and loads that saved session before the test body runs — so
tests start already logged in.

`pnpm playwright` (and `pnpm playwright:setup`) always re-run `setup` first,
regenerating `.auth/user.json` fresh. `pnpm playwright:run` skips `setup`
entirely and reuses whatever session is already on disk — see
[Running](#running).

`.auth/user.json` contains a live, real session (the actual `osac-access`
cookie) — it's gitignored and must never be committed or shared, and
`auth.setup.ts` restricts it to `0600` (and its directory to `0700`) so other
local accounts on a shared machine can't read it.

If the target realm's login page uses a custom Keycloak theme, the field/
button selectors in `auth.setup.ts` (written against Keycloak's default
theme) may need adjusting.

## Smoke Test

`src/smoke.spec.ts` is the one persisted, committed spec in this package — a
harness self-check, not a feature test. It logs in (via `setup`) and asserts
that the app actually loads and renders its masthead. Its job is to answer
"is the harness broken, or is my change broken?" before you spend time
writing a real `scratch/` spec: if the smoke test fails, the problem is
almost certainly auth, config, or environment — not the change you're trying
to verify.

It runs automatically as part of `pnpm playwright` (and standalone via `pnpm
--filter @osac/playwright exec playwright test --project=smoke`). Unlike
`scratch/` specs, do not delete or repurpose it, and do not add more specs
here — this package still isn't a test suite; one fixed sanity check is the
exception, not a precedent.

## Writing a test

**This is not a test suite.** Beyond the one smoke test above, this package
provides the harness (auth + config) only. Test specs are throwaway, written
ad hoc for a specific manual-verification task (typically by an AI agent
working through a change) and run once, then discarded.

Specs live under `scratch/` — a gitignored directory that only exists on your
machine. Nothing written there is ever committed, so there's nothing to clean
up and no risk of it landing in a PR. Do not write specs under `src/` (that's
reserved for the harness and the smoke test), and do not treat anything under
`scratch/` as regression coverage — if a change needs persisted UI coverage,
that's a job for `osac-test-infra`'s gRPC-level suite, not this harness.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

```bash
mkdir -p apps/playwright/scratch
cat > apps/playwright/scratch/check.spec.ts <<'EOF'
import { expect, test } from '@playwright/test';

test('loads the authenticated dashboard', async ({ page }) => {
await page.goto('/');

await expect(page.getByRole('button', { name: 'Account menu' })).toBeVisible();
});
EOF
```

It runs in the `chromium` project — already authenticated via `auth.setup.ts`,
no need to handle login in the test itself. Run it with:

```bash
export OSAC_UI_BASE_URL=... OSAC_USERNAME=...
read -rs OSAC_PASSWORD && export OSAC_PASSWORD
pnpm playwright
```

`pnpm playwright` runs every spec under `scratch/` — delete the file when you're done
with it (or leave it; it's gitignored either way, but a stale spec will run
again next time).
17 changes: 17 additions & 0 deletions apps/playwright/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "@osac/playwright",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"playwright": "playwright test",
"playwright:setup": "playwright test --project=setup",
"playwright:run": "playwright test --project=chromium --no-deps",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@playwright/test": "^1.62.0",
"@types/node": "^20.19.41",
"typescript": "~5.9.3"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
79 changes: 79 additions & 0 deletions apps/playwright/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { defineConfig, devices } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

import { AUTH_FILE } from './src/auth-file';

const baseURL = process.env.OSAC_UI_BASE_URL;
if (!baseURL) {
throw new Error('OSAC_UI_BASE_URL must be set to the URL of a running osac-ui instance.');
}
// Diagnostics below never interpolate the raw value — a malformed OSAC_UI_BASE_URL
// can carry embedded credentials or an internal hostname that shouldn't land
// in CI logs.
const invalidBaseURLError = new Error(
'OSAC_UI_BASE_URL must be an absolute http(s) URL with no embedded username/password.',
);
let parsedBaseURL: URL;
try {
parsedBaseURL = new URL(baseURL);
} catch {
throw invalidBaseURLError;
}
if (parsedBaseURL.protocol !== 'http:' && parsedBaseURL.protocol !== 'https:') {
throw invalidBaseURLError;
}
if (parsedBaseURL.username || parsedBaseURL.password) {
throw invalidBaseURLError;
}

// Ad hoc specs (typically written by an AI agent to manually verify a change)
// live in this gitignored scratch directory, never under src/ — that keeps
// the "throwaway, not a test suite" boundary obvious to anyone browsing the
// package, instead of relying on everyone remembering src/*.spec.ts is ignored.
const scratchDir = path.join(path.dirname(fileURLToPath(import.meta.url)), 'scratch');
fs.mkdirSync(scratchDir, { recursive: true });

export default defineConfig({
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
reporter: 'html',
expect: {
timeout: 15_000,
},
use: {
baseURL,
trace: 'on-first-retry',
// Dev/lab cluster routes commonly present a self-signed or cluster-internal
// CA cert that Chromium doesn't trust by default (the same reason manual
// testing against these environments always needs curl -k). Opt-in only —
// this flow submits a real Keycloak password, so TLS verification stays on
// by default.
ignoreHTTPSErrors: process.env.IGNORE_HTTPS_ERRORS === 'true',
},
projects: [
{
name: 'setup',
testDir: './src',
testMatch: /auth\.setup\.ts/,
// Traces record fill() arguments — never trace the project that types the
// real Keycloak password, even if a future CI run retries it.
use: { ...devices['Desktop Chrome'], trace: 'off' },
},
{
name: 'smoke',
testDir: './src',
testMatch: /smoke\.spec\.ts/,
use: { ...devices['Desktop Chrome'], storageState: AUTH_FILE },
dependencies: ['setup'],
},
{
name: 'chromium',
testDir: scratchDir,
use: { ...devices['Desktop Chrome'], storageState: AUTH_FILE },
dependencies: ['setup'],
},
],
});
1 change: 1 addition & 0 deletions apps/playwright/src/auth-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const AUTH_FILE = '.auth/user.json';
37 changes: 37 additions & 0 deletions apps/playwright/src/auth.setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { expect, test as setup } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';

import { AUTH_FILE } from './auth-file';

setup('authenticate', async ({ page }) => {
const username = process.env.OSAC_USERNAME;
const password = process.env.OSAC_PASSWORD;
if (!username || !password) {
throw new Error('OSAC_USERNAME and OSAC_PASSWORD must be set to a valid Keycloak test user.');
}

// The app checks /api/login/info on load and, if unauthenticated, immediately
// redirects the browser to Keycloak itself (apps/app-frontend/src/hooks/oidc-login.tsx)
// — there is no login button to click first. This realm's theme is a two-step
// identifier-first flow: username + "Sign In" submits to a second screen with
// the password field, then the same "Sign In" label submits that too. Field/
// button names match Keycloak's default theme; a custom theme may differ.
await page.goto('/');
await page.getByLabel('Username or email').fill(username);
await page.getByRole('button', { name: 'Sign In' }).click();
// getByLabel('Password') is ambiguous — it also matches the theme's "Show
// password" toggle button, which shares the same label association. A
// native input[type=password] has no ARIA role, so getByRole('textbox')
// won't match it either.
await page.locator('input[type="password"]').fill(password);
await page.getByRole('button', { name: 'Sign In' }).click();
Comment thread
coderabbitai[bot] marked this conversation as resolved.

await expect(page.getByRole('button', { name: 'Account menu' })).toBeVisible();

await page.context().storageState({ path: AUTH_FILE });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// AUTH_FILE holds a live, real Keycloak session cookie — restrict it to the
// current user so other local accounts on a shared machine can't reuse it.
fs.chmodSync(path.dirname(AUTH_FILE), 0o700);
fs.chmodSync(AUTH_FILE, 0o600);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
});
7 changes: 7 additions & 0 deletions apps/playwright/src/smoke.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { expect, test } from '@playwright/test';

test('authenticated app loads and shows the masthead', async ({ page }) => {
await page.goto('/');

await expect(page.getByRole('banner')).toBeVisible();
});
4 changes: 4 additions & 0 deletions apps/playwright/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src", "playwright.config.ts"]
}
Loading
Loading