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
40 changes: 39 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 15 additions & 0 deletions frontend/e2e/auth.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
30 changes: 30 additions & 0 deletions frontend/e2e/fixtures/authentication.ts
Original file line number Diff line number Diff line change
@@ -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");
}
116 changes: 116 additions & 0 deletions frontend/e2e/user-management.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
6 changes: 6 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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",
Expand Down
47 changes: 47 additions & 0 deletions frontend/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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",
});
26 changes: 26 additions & 0 deletions frontend/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
12 changes: 12 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading