diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c99c34..dc499a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -106,6 +106,44 @@ jobs: # asserts those generated files are present and coherent in git. - run: yarn build + e2e: + needs: changes + if: needs.changes.outputs.backend == 'true' || needs.changes.outputs.frontend == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + with: + version: "latest" + python-version: "3.12" + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: yarn + cache-dependency-path: frontend/yarn.lock + - uses: extractions/setup-just@v3 + + - run: uv sync --all-extras --dev + working-directory: backend + - run: yarn install --frozen-lockfile + - run: yarn playwright install --with-deps chromium + - run: yarn e2e + + - name: Upload Playwright diagnostics + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: | + frontend/playwright-report/ + frontend/test-results/ + retention-days: 14 + if-no-files-found: ignore + # The frontend is GENERATED from the running backend, and the generator fails SILENTLY # (fetch errors are warnings; it falls back to the committed output). So committed hooks # can drift away from the backend with nothing to catch it. This job boots the real @@ -171,7 +209,7 @@ jobs: ci-ok: # One required status check that still passes when path filters skip a job if: always() - needs: [backend-test, backend-coverage, frontend, codegen-drift] + needs: [backend-test, backend-coverage, frontend, e2e, codegen-drift] runs-on: ubuntu-latest steps: - name: Fail if any needed job failed diff --git a/.gitignore b/.gitignore index 86474d4..b741753 100644 --- a/.gitignore +++ b/.gitignore @@ -90,6 +90,11 @@ yarn-debug.log* yarn-error.log* pnpm-debug.log* +# Playwright end-to-end test artifacts and authentication state +frontend/playwright-report/ +frontend/test-results/ +frontend/.auth/ + # ---------- Release artifacts ---------- /blok.zip diff --git a/frontend/e2e/auth.spec.ts b/frontend/e2e/auth.spec.ts new file mode 100644 index 0000000..97a46a2 --- /dev/null +++ b/frontend/e2e/auth.spec.ts @@ -0,0 +1,15 @@ +import { expect, test } from "@playwright/test"; +import { login, logout } from "./fixtures/authentication"; + +test("admin can sign in and sign out", async ({ page }) => { + await login(page, "admin", "admin"); + await expect( + page.getByRole("button", { name: "Account menu" }), + ).toBeVisible(); + + await page.getByRole("button", { name: "Account menu" }).click(); + await expect(page.getByText("admin ยท admin")).toBeVisible(); + await page.keyboard.press("Escape"); + await logout(page); + await expect(page.getByRole("button", { name: "Sign in" })).toBeVisible(); +}); diff --git a/frontend/e2e/fixtures/authentication.ts b/frontend/e2e/fixtures/authentication.ts new file mode 100644 index 0000000..501b629 --- /dev/null +++ b/frontend/e2e/fixtures/authentication.ts @@ -0,0 +1,30 @@ +import { expect, type Page } from "@playwright/test"; + +export async function login(page: Page, username: string, password: string) { + await page.goto("/login"); + await page.getByLabel("Username").fill(username); + await page.getByLabel("Password").fill(password); + await page.getByRole("button", { name: "Sign in" }).click(); + await expect(page).toHaveURL("/"); +} + +export async function logout(page: Page) { + await page.getByRole("button", { name: "Account menu" }).click(); + await page.getByRole("menuitem", { name: "Log out" }).click(); + await expect(page).toHaveURL("/login"); +} + +export async function expectLoginRejected( + page: Page, + username: string, + password: string, +) { + await page.goto("/login"); + await page.getByLabel("Username").fill(username); + await page.getByLabel("Password").fill(password); + await page.getByRole("button", { name: "Sign in" }).click(); + await expect(page.getByRole("alert")).toHaveText( + "Invalid username or password.", + ); + await expect(page).toHaveURL("/login"); +} diff --git a/frontend/e2e/user-management.spec.ts b/frontend/e2e/user-management.spec.ts new file mode 100644 index 0000000..1134265 --- /dev/null +++ b/frontend/e2e/user-management.spec.ts @@ -0,0 +1,116 @@ +import { expect, test, type Page, type TestInfo } from "@playwright/test"; +import { expectLoginRejected, login, logout } from "./fixtures/authentication"; + +const ADMIN_USERNAME = "admin"; +const ADMIN_PASSWORD = "admin"; + +function uniqueUsername(prefix: string, testInfo: TestInfo) { + return `${prefix}-${testInfo.workerIndex}-${testInfo.retry}-${Date.now()}`; +} + +async function openUserManagement(page: Page) { + await page.getByRole("button", { name: "Account menu" }).click(); + await page.getByRole("menuitem", { name: "Manage users" }).click(); + await expect(page).toHaveURL("/admin/users"); + await expect(page.getByText("Manage accounts and roles.")).toBeVisible(); +} + +async function createOperator(page: Page, username: string, password: string) { + await page.getByRole("button", { name: "Add user" }).click(); + const dialog = page.getByRole("dialog", { name: "Add user" }); + await dialog.getByLabel("Username").fill(username); + await dialog.getByLabel("Password").fill(password); + await dialog.getByRole("button", { name: "Create" }).click(); + await expect( + page.getByRole("row").filter({ hasText: username }), + ).toBeVisible(); +} + +async function deleteUser(page: Page, username: string) { + await page.getByRole("button", { name: `Delete ${username}` }).click(); + const dialog = page.getByRole("alertdialog", { + name: `Delete '${username}'?`, + }); + await dialog.getByRole("button", { name: "Delete" }).click(); + await expect(page.getByRole("row").filter({ hasText: username })).toHaveCount( + 0, + ); +} + +test("admin can create and delete an operator", async ({ page }, testInfo) => { + const username = uniqueUsername("e2e-created", testInfo); + const password = "operator-test-password"; + + await login(page, ADMIN_USERNAME, ADMIN_PASSWORD); + await openUserManagement(page); + await createOperator(page, username, password); + await deleteUser(page, username); + + await logout(page); + await expectLoginRejected(page, username, password); +}); + +test("operator can change their own password", async ({ page }, testInfo) => { + const username = uniqueUsername("e2e-password", testInfo); + const oldPassword = "old-test-password"; + const newPassword = "new-test-password"; + + await login(page, ADMIN_USERNAME, ADMIN_PASSWORD); + await openUserManagement(page); + await createOperator(page, username, oldPassword); + await logout(page); + + await login(page, username, oldPassword); + await page.getByRole("button", { name: "Account menu" }).click(); + await page.getByRole("menuitem", { name: "Change password" }).click(); + + const dialog = page.getByRole("dialog", { name: "Change password" }); + await dialog.getByLabel("Current password").fill(oldPassword); + await dialog.getByLabel("New password", { exact: true }).fill(newPassword); + await dialog.getByLabel("Confirm new password").fill(newPassword); + await dialog.getByRole("button", { name: "Change password" }).click(); + + await expect(dialog).toHaveCount(0); + await expect( + page.getByRole("button", { name: "Account menu" }), + ).toBeVisible(); + await logout(page); + + await expectLoginRejected(page, username, oldPassword); + await login(page, username, newPassword); + await logout(page); + + await login(page, ADMIN_USERNAME, ADMIN_PASSWORD); + await openUserManagement(page); + await deleteUser(page, username); +}); + +test("the last admin cannot be demoted, disabled, or deleted", async ({ + page, +}) => { + await login(page, ADMIN_USERNAME, ADMIN_PASSWORD); + await openUserManagement(page); + + const adminRow = page.getByRole("row").filter({ hasText: ADMIN_USERNAME }); + const roleSelect = adminRow.getByRole("combobox"); + const enabledSwitch = adminRow.getByRole("switch"); + + await expect(roleSelect).toHaveText("admin"); + await roleSelect.click(); + await page.getByRole("option", { name: "operator" }).click(); + await expect( + page.getByText("Cannot remove the last admin").last(), + ).toBeVisible(); + await expect(roleSelect).toHaveText("admin"); + + await expect(enabledSwitch).toBeChecked(); + await enabledSwitch.click(); + await expect( + page.getByText("Cannot remove the last admin").last(), + ).toBeVisible(); + await expect(enabledSwitch).toBeChecked(); + + await expect( + adminRow.getByRole("button", { name: `Delete ${ADMIN_USERNAME}` }), + ).toBeDisabled(); +}); diff --git a/frontend/package.json b/frontend/package.json index e1db895..e948a99 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,11 @@ "lint": "eslint .", "test": "vitest run", "test:watch": "vitest", + "e2e": "playwright test", + "e2e:ui": "playwright test --ui", + "e2e:headed": "playwright test --headed", + "e2e:debug": "playwright test --debug", + "e2e:report": "playwright show-report", "preview": "vite preview" }, "dependencies": { @@ -81,6 +86,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@playwright/test": "^1.62.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@types/node": "^25.0.10", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..5e5049a --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,47 @@ +import { defineConfig, devices } from "@playwright/test"; +import { readFileSync } from "node:fs"; + +const rootEnv = readFileSync(new URL("../.env", import.meta.url), "utf8"); +const frontendPort = + process.env.FRONTEND_PORT ?? rootEnv.match(/^FRONTEND_PORT=(\d+)$/m)?.[1]; + +if (!frontendPort) { + throw new Error("FRONTEND_PORT is missing from the root .env file"); +} + +const baseURL = `http://127.0.0.1:${frontendPort}`; + +export default defineConfig({ + testDir: "./e2e", + // The current E2E stack intentionally shares one disposable auth database. + // Keep tests serial until each worker gets its own backend and database. + fullyParallel: false, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: [ + ["list"], + ["html", { outputFolder: "playwright-report", open: "never" }], + ], + use: { + baseURL, + screenshot: "only-on-failure", + trace: "retain-on-failure", + video: "retain-on-failure", + }, + webServer: { + // The dedicated recipe starts the backend first, uses a disposable account DB, + // and skips schema codegen so E2E runs cannot dirty committed generated files. + command: "cd .. && just dev-e2e", + url: baseURL, + timeout: 120_000, + reuseExistingServer: !process.env.CI, + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + outputDir: "test-results", +}); diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 4de2304..b847b54 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -784,6 +784,13 @@ resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== +"@playwright/test@^1.62.1": + version "1.62.1" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.62.1.tgz#68e1aaf6e480e1923936dee6c66fae1921d3a65a" + integrity sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ== + dependencies: + playwright "1.62.1" + "@radix-ui/number@1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@radix-ui/number/-/number-1.1.1.tgz#7b2c9225fbf1b126539551f5985769d0048d9090" @@ -2967,6 +2974,11 @@ framer-motion@^12.34.3: motion-utils "^12.29.2" tslib "^2.4.0" +fsevents@2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + fsevents@~2.3.2, fsevents@~2.3.3: version "2.3.3" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" @@ -3658,6 +3670,20 @@ picomatch@^4.0.3: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042" integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== +playwright-core@1.62.1: + version "1.62.1" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.62.1.tgz#120f67a19181bfd183c60fa903c0d99330b56785" + integrity sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw== + +playwright@1.62.1: + version "1.62.1" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.62.1.tgz#8447b6755e8aec85a3cb7207c823e3ed2fc66700" + integrity sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg== + dependencies: + playwright-core "1.62.1" + optionalDependencies: + fsevents "2.3.2" + postcss@^8.5.6: version "8.5.6" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c" diff --git a/justfile b/justfile index 34d1535..501a29f 100644 --- a/justfile +++ b/justfile @@ -61,6 +61,18 @@ dev: (cd frontend && exec yarn dev) & wait +# Isolated stack for Playwright. Vite's test mode skips backend schema codegen so an +# E2E run cannot dirty the committed generated frontend files. +dev-e2e: + #!/usr/bin/env bash + set -euo pipefail + trap 'trap - EXIT; kill 0' EXIT INT TERM + rm -f frontend/test-results/e2e-auth.db + (cd backend && NEWSWITCH_AUTH_DB=../frontend/test-results/e2e-auth.db exec uv run uvicorn main:app --host {{BACKEND_HOST}} --port {{BACKEND_PORT}}) & + just wait-backend + (cd frontend && exec yarn dev --mode test) & + wait + # ---------- quality ---------- lint: