Files
ersatztv/web/e2e/boot-gate.spec.ts
T
timothy d8c0b3e752 feat(445,533): headless Playwright UI-E2E flows + fix e2e-local readiness probe [decisions-edit]
Adds the last deferred #299/#363 follow-up: the flows that CANNOT be expressed
as curl calls. Scope rule (the durable part) — assert only what the curl
harness structurally cannot reach:

  1. client-side form validation (the Setup confirm-password gate is pure React
     state and makes no request, so there is no HTTP contract to assert)
  2. AuthGate's RENDERED states (Setup vs Login vs app)
  3. the session cookie authenticating the SPA's OWN /api XHRs — curl proves the
     cookie works for curl, not that the app sends it
  4. sign-out through the UserMenu back to the login gate

New: web/e2e/boot-gate.spec.ts, web/playwright.config.ts, scripts/e2e-ui.sh
(owns the whole lifecycle: fresh config dir -> boot -> specs -> always kill).

Runs as a second step of the EXISTING advisory `functional-e2e` job rather than
a new job: the dominant cost there is `npm ci` + the Release build, both already
done, so this adds ~5s instead of duplicating a heavy job. It boots its own
fresh instance on port 8410 because the first spec asserts the one-shot Setup
gate that the curl step has already claimed on its config dir.

Determinism (the issue asked for it explicitly): `serial`, `workers: 1`,
`retries: 0` even in CI — a retry would let a flaky flow merge looking green.
Measured 5 consecutive clean runs, ~2s each.

Pins all five `container:` jobs to the toolchain image built by the preceding
commit, which bakes `chromium-headless-shell`.

Non-obvious coupling fixed: vitest's default include glob would have collected
web/e2e/*.spec.ts and run it under jsdom. Excluded `e2e/**` by spreading
`configDefaults.exclude` rather than narrowing `include` to `src/**`, because
web/scripts/ holds a real vitest test an src-only include would silently stop
running.

`RebuildSearchIndexHandler` logs one of two mutually-exclusive lines just before
`SystemStartup.SearchIndexIsReady()`:

  fresh config  -> "Done migrating search index in {Duration}"
  reused config -> "Search index is already version {Version}"

The probe watched only the first, so a reused dir waited out the full 120s
timeout and then killed a perfectly healthy server. Widened to a `grep -Eq`
alternation; the handler's if/else is exhaustive, so the pair covers every path
to readiness.

Verified with a negative control: on a reused dir the server is ready in 2s via
the "already version" line, and the OLD probe string is genuinely ABSENT from
that run's log — so the old code would have hung, i.e. the fix is load-bearing
rather than incidentally passing.

The "prefer a fresh config dir" guidance stays: that guards state bleed, which
is a separate concern from the probe hanging.

- `wait "$PID"` in the cleanup trap was a NO-OP: the server is a grandchild
  (launched in e2e-local.sh's subshell, which then exits), so `wait` fails
  instantly and was swallowed by `|| true` — cleanup did not actually ensure the
  port was released, exactly what its comment claimed. Replaced with a bounded
  `kill -0` poll, then SIGKILL.
- Added a port pre-flight check: previously an occupied port surfaced as a 120s
  readiness timeout that reads like a broken build. Now fails in 0s naming the
  PIDs, and warns against blanket-killing `dotnet ErsatzTV.dll` (that reaps
  other sessions' servers).

- UI-E2E: 5x clean (3 specs, ~2s); back-to-back runs pass with no manual cleanup
- curl harness unaffected by the boot-script change: 45/45 PASS
- web: 983 tests / 105 files green; typecheck + lint clean
- vitest collection verified: excludes web/e2e, still collects web/scripts
- Dockerfile sequence + browser launch validated verbatim in a container on the
  real amd64 base before committing; chromium launches as root with NO sandbox
  opt-out needed
- decisions validator green; catalog regenerated
- docs/decisions.md TOC repaired: it had drifted to 69 of 97 records and held a
  dangling anchor to the #72 record that #415 superseded into archive/.
  Regenerated with a generator validated against the 68 existing anchors (0
  mismatches) -> 97/97, no dangling, no duplicates.

Docs: docs/e2e-local.md (new "UI-E2E harness" section), docs/ci-cd.md (toolchain
image + UI-E2E step), docs/testing.md, docs/README.md, docs/decisions.md
(new `ci.ui-e2e-harness` record; `ci.functional-e2e-harness` amended — its Rule
said "curl-only", now accurate).

Refs #445 #533
2026-07-25 14:03:59 +02:00

142 lines
7.3 KiB
TypeScript

import { expect, test, type Page } from '@playwright/test';
// UI-interactive boot-gate flows (ersatztv#445), deferred from #363 because they cannot be expressed
// as curl calls. `scripts/e2e-functional.sh` already asserts the auth *HTTP* contracts (setup-claim
// 200/409, login 401/200, CSRF 403, security-stamp rotation). What it CANNOT reach, and what these
// specs exist for:
//
// 1. Client-side form validation — the Setup card's confirm-password gate is pure React state.
// No request is made, so there is no HTTP contract to assert.
// 2. AuthGate's state machine as rendered — that `setupRequired` yields the Setup card, a
// configured server yields the Login card, and a 401 yields neither.
// 3. The session cookie authenticating the SPA's OWN fetch layer. curl proves the cookie works for
// curl; it cannot prove the browser sends it on the app's `/api/v1/*` XHRs, which is the thing
// that actually breaks for a user.
// 4. Sign-out wired through the UserMenu back to the login gate.
//
// SERVER STATE IS SHARED AND PARTLY ONE-SHOT. The setup-claim can happen exactly once per config
// dir, so these tests are `serial` and the claim must run first. `scripts/e2e-ui.sh` boots a FRESH
// instance so the first test always finds `setupRequired: true`.
//
// Each `test` gets its own browser context (so its own cookie jar) — that is deliberate: it gives
// tests 2 and 3 a genuinely signed-out browser without a logout dance. Anything that depends on
// holding a session across steps must therefore stay INSIDE one test.
const USERNAME = 'e2eadmin';
const PASSWORD = 'e2e-Passw0rd!';
// The Setup/Login cards use the shared <Input>, which wraps its <input> in a <label> — so the
// accessible name picks up the error text too when a field is invalid ("Confirm password Passwords
// do not match."). Address the fields by their unique placeholders instead, which are stable
// regardless of validation state.
const setupUsername = (page: Page) => page.getByPlaceholder('Choose a username');
const setupPassword = (page: Page) => page.getByPlaceholder('Choose a password');
const setupConfirm = (page: Page) => page.getByPlaceholder('Re-enter the password');
const loginUsername = (page: Page) => page.getByPlaceholder('Username', { exact: true });
const loginPassword = (page: Page) => page.getByPlaceholder('Password', { exact: true });
/** The authenticated landmark: the TopBar UserMenu button renders the signed-in username. */
const userMenu = (page: Page) => page.getByRole('button', { name: new RegExp(USERNAME) });
/** Shown by UnauthorizedBanner on any 401 from the SPA's fetch layer. Must never appear when authed. */
const authBanner = (page: Page) => page.getByText('Your session has expired or you are signed out.');
test.describe.serial('boot gate', () => {
test('setup: a fresh instance gates on Setup, enforces confirmation, and the claim enters the app', async ({
page
}) => {
await page.goto('/app');
// (1) A fresh config dir => `/api/v1/auth/config.setupRequired` is true => the Setup card.
await expect(page.getByText('Welcome to ChicoryTV')).toBeVisible();
await expect(page.getByText('Create the first administrator account')).toBeVisible();
const submit = page.getByRole('button', { name: 'Create account' });
// (2) Client-side confirm-password validation — no HTTP request is involved, so this contract
// exists ONLY in the browser. Submit stays disabled until the two passwords actually match.
await expect(submit).toBeDisabled();
await setupUsername(page).fill(USERNAME);
await setupPassword(page).fill(PASSWORD);
await expect(submit).toBeDisabled(); // confirm still empty
await setupConfirm(page).fill(`${PASSWORD}-mismatch`);
await expect(page.getByText('Passwords do not match.')).toBeVisible();
await expect(submit).toBeDisabled();
await setupConfirm(page).fill(PASSWORD);
await expect(page.getByText('Passwords do not match.')).toBeHidden();
await expect(submit).toBeEnabled();
// (3) The claim returns a session and AuthGate hands off to the app shell.
await submit.click();
await expect(userMenu(page)).toBeVisible();
// (4) The cookie survives a full reload — AuthGate's `/auth/session` path, not the claim path.
await page.reload();
await expect(userMenu(page)).toBeVisible();
await expect(page.getByText('Welcome back')).toBeHidden();
// (5) The session authenticates the SPA's OWN `/api/v1/*` fetches — the assertion curl
// structurally cannot make. Navigating to Channels must render server data, not the 401 banner.
//
// NOTE: a fresh config is NOT empty. `DbInitializer` seeds one default channel (Number "1",
// Name "ErsatzTV") on every new DB, so the Channels *empty state* is unreachable here — assert
// the seeded row instead. Asserting the table content (not merely "the screen rendered") is what
// makes this prove the fetch succeeded: an unauthenticated fetch yields a table with no rows.
await page.goto('/app/channels');
await expect(page.getByRole('heading', { level: 1, name: 'Channels' })).toBeVisible();
await expect(page.getByRole('table', { name: 'Channels lineup' })).toBeVisible();
await expect(page.getByRole('cell', { name: /ErsatzTV/ }).first()).toBeVisible();
await expect(page.getByText('1 of 1 channels')).toBeVisible();
await expect(authBanner(page)).toBeHidden();
});
test('login: a configured server gates on Login; a wrong password errors inline', async ({ page }) => {
await page.goto('/app');
// Setup is claimed, so this signed-out browser must get Login — NOT Setup, and not the app.
await expect(page.getByText('Welcome back')).toBeVisible();
await expect(page.getByText('Create the first administrator account')).toBeHidden();
const submit = page.getByRole('button', { name: 'Sign in' });
await expect(submit).toBeDisabled();
await loginUsername(page).fill(USERNAME);
await loginPassword(page).fill('definitely-the-wrong-password');
await submit.click();
// A 401 renders inline on the card; login() suppresses the global banner for exactly this case.
await expect(page.getByText('Invalid username or password.')).toBeVisible();
await expect(authBanner(page)).toBeHidden();
await expect(userMenu(page)).toBeHidden();
// The same form then accepts the correct password (proving the 401 was credential-specific).
await loginPassword(page).fill(PASSWORD);
await expect(page.getByText('Invalid username or password.')).toBeHidden();
await submit.click();
await expect(userMenu(page)).toBeVisible();
});
test('sign out through the UserMenu returns to the Login gate', async ({ page }) => {
await page.goto('/app');
await loginUsername(page).fill(USERNAME);
await loginPassword(page).fill(PASSWORD);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(userMenu(page)).toBeVisible();
await userMenu(page).click();
await page.getByRole('button', { name: 'Sign out' }).click();
// Back to the login card, and the app shell is gone.
await expect(page.getByText('Welcome back')).toBeVisible();
await expect(userMenu(page)).toBeHidden();
// The revoked cookie must not resurrect the session on a reload (rotated security stamp).
await page.reload();
await expect(page.getByText('Welcome back')).toBeVisible();
});
});