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
83 changes: 83 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
name: e2e

on:
pull_request:
branches: [main]
push:
branches: [main]

# Cancel in-progress runs when a new commit lands on the same PR.
concurrency:
group: e2e-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 20
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: healthlog
POSTGRES_PASSWORD: healthlog
POSTGRES_DB: healthlog
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U healthlog"
--health-interval 5s
--health-timeout 5s
--health-retries 10

env:
DATABASE_URL: postgresql://healthlog:healthlog@localhost:5432/healthlog?schema=public
ENCRYPTION_KEYS: '{"v1":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}'
ENCRYPTION_ACTIVE_KEY_ID: v1
API_TOKEN_HMAC_KEY: ci-hmac-key-32-bytes-min-padding-x
AUTH_RP_NAME: HealthLog
AUTH_RP_ID: localhost
AUTH_RP_ORIGIN: http://localhost:3000
NODE_ENV: production

steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4
with:
version: 10

- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm

- run: pnpm install --frozen-lockfile

- run: pnpm db:generate

- run: pnpm db:migrate:deploy

- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
playwright-${{ runner.os }}-

- run: pnpm exec playwright install --with-deps chromium

- run: pnpm exec next build

- run: pnpm exec playwright test
env:
E2E_BASE_URL: http://localhost:3000

- name: Upload Playwright HTML report on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 7
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
# testing
/coverage

# playwright e2e
/test-results
/playwright-report
/playwright/.cache

# next.js
/.next/
/out/
Expand Down
44 changes: 44 additions & 0 deletions e2e/a11y.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";

/**
* Accessibility regression gate — fails the build if any WCAG 2.1 AA
* violation lands in the `serious` or `critical` bucket on a public
* surface. Authenticated pages are out of scope here (need seeded
* data); they're covered by the per-flow specs that already touch
* those routes.
*/
test.describe("axe-core public surfaces", () => {
for (const path of ["/auth/login"]) {
test(`${path} has no serious or critical a11y violations`, async ({
page,
}) => {
await page.goto(path);
// Wait briefly for any reduced-motion fades to settle.
await page.waitForLoadState("networkidle");

const results = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();

const blocking = results.violations.filter(
(v) => v.impact === "serious" || v.impact === "critical",
);

if (blocking.length > 0) {
// Pretty-print so failures are actionable in CI logs.
console.log(
"axe violations:\n" +
blocking
.map(
(v) =>
` - [${v.impact}] ${v.id}: ${v.help}\n ${v.nodes.length} node(s)\n ${v.helpUrl}`,
)
.join("\n"),
);
}

expect(blocking).toHaveLength(0);
});
}
});
34 changes: 34 additions & 0 deletions e2e/auth-redirect.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { expect, test } from "@playwright/test";

/**
* The proxy at src/proxy.ts is the single gate enforcing auth on every
* non-public path. A regression that accidentally adds a route to
* PUBLIC_PATHS, or breaks the redirect, would expose unauthenticated
* surfaces. Verify a few representative routes round-trip to /auth/login.
*/
test.describe("proxy auth gate", () => {
for (const path of [
"/",
"/dashboard",
"/medications",
"/admin",
"/insights",
]) {
test(`${path} redirects to /auth/login when no session`, async ({
page,
}) => {
const response = await page.goto(path, { waitUntil: "domcontentloaded" });
expect(response).not.toBeNull();
// Either the proxy hands back a 307/308, or the navigation lands
// at /auth/login after redirect; both are acceptable proof.
expect(page.url()).toMatch(/\/auth\/login(\?|$)/);
});
}

test("public paths are reachable without a session", async ({ page }) => {
// /auth/login is in PUBLIC_PATHS — must be served, not bounced anywhere.
await page.goto("/auth/login");
expect(page.url()).toMatch(/\/auth\/login(\?|$)/);
await expect(page.locator("body")).toBeVisible();
});
});
49 changes: 49 additions & 0 deletions e2e/locale-switch.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { expect, test } from "@playwright/test";

/**
* Locale-key drift smoke check — switch the cookie between EN and DE
* and assert the login page (the only public surface that's translated)
* never shows raw i18n keys like `auth.welcomeBack`.
*
* Drift catches: a typo'd t("foo.bar") with no key in either locale
* file would make the raw key surface in the DOM. messages/de.json and
* messages/en.json key parity is enforced by unit tests; this is the
* runtime cousin.
*/
test.describe("locale switch", () => {
for (const locale of ["en", "de"] as const) {
test(`renders /auth/login in ${locale} without raw i18n keys`, async ({
page,
context,
}) => {
await context.addCookies([
{
name: "healthlog_locale",
value: locale,
url: page.url() || "http://localhost:3000",
},
]);
await page.goto("/auth/login");

const bodyText = await page.locator("body").innerText();

// i18n keys look like `section.subkey` — letters, numbers,
// underscores, dots — and are never wrapped in normal sentences,
// so a regex match anywhere in body text is a sign the lookup
// fell through.
const rawKeyPattern = /\b[a-z]+(?:[A-Z][a-z]+)?\.[a-z][A-Za-z0-9_.]+\b/;
const match = bodyText.match(rawKeyPattern);

// Whitelist: filenames or version strings can look like keys,
// skip those by requiring at least one camelCase segment.
if (match) {
const candidate = match[0];
const looksLikeKey = /\.[a-z][A-Z]/.test(candidate);
expect(
looksLikeKey,
`Possible raw i18n key in ${locale}: ${candidate}`,
).toBe(false);
}
});
}
});
53 changes: 53 additions & 0 deletions e2e/login.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { expect, test } from "@playwright/test";

/**
* Smoke checks on the login surface — must render, must wire the
* password-manager autofill hints (autoComplete="username" /
* "current-password"), must let the user submit.
*
* These do NOT log in (no DB seed required); the password-flow
* roundtrip is exercised by the integration test in
* tests/integration/auth-flow.test.ts.
*/
test.describe("login page", () => {
test("renders username + password inputs with the right autoComplete", async ({
page,
}) => {
await page.goto("/auth/login");

const username = page.getByLabel(/username|benutzername/i).first();
await expect(username).toBeVisible();
await expect(username).toHaveAttribute("autoComplete", /username|email/);

const password = page.locator('input[type="password"]').first();
await expect(password).toBeVisible();
await expect(password).toHaveAttribute("autoComplete", "current-password");
});

test("rejects an obviously-wrong credential pair", async ({ page }) => {
await page.goto("/auth/login");

await page
.getByLabel(/username|benutzername/i)
.first()
.fill("nobody-here");
await page.locator('input[type="password"]').first().fill("not-the-pw");

// Intercept the API call so the test does not depend on a real
// backend rejecting the credentials. The login form's error
// surfacing is what we actually want to prove here.
await page.route("**/api/auth/login", (route) =>
route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ error: "Invalid credentials" }),
}),
);

await page.getByRole("button", { name: /login|anmelden|sign in/i }).click();

await expect(
page.getByText(/invalid credentials|ungültig|falsch/i),
).toBeVisible({ timeout: 5_000 });
});
});
30 changes: 30 additions & 0 deletions e2e/version.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { expect, test } from "@playwright/test";

/**
* /api/version is the public, unauthenticated endpoint that drives
* the Settings → About surface and acts as the container's healthcheck
* target. If this breaks, every deployed image's healthcheck fails and
* Coolify pulls the container out of rotation.
*/
test.describe("public version endpoint", () => {
test("returns the running version + license", async ({ request }) => {
const res = await request.get("/api/version");
expect(res.status()).toBe(200);

const json = await res.json();
expect(json.data).toBeDefined();
expect(typeof json.data.version).toBe("string");
expect(json.data.version).toMatch(/^\d+\.\d+\.\d+/);
expect(json.data.license).toBeDefined();
});

test("does not require authentication", async ({ request }) => {
// No cookie, no Authorization header — must still respond 200 so
// the docker healthcheck and the in-app "check for updates" button
// both work without a session.
const res = await request.get("/api/version", {
headers: { "X-Client-Type": "anonymous" },
});
expect(res.status()).toBe(200);
});
});
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:integration": "vitest run --config vitest.integration.config.mts"
"test:integration": "vitest run --config vitest.integration.config.mts",
"e2e": "playwright test",
"e2e:ui": "playwright test --ui"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1045.0",
Expand Down Expand Up @@ -51,6 +53,8 @@
"zxcvbn-typescript": "^5.0.1"
},
"devDependencies": {
"@axe-core/playwright": "^4.11.3",
"@playwright/test": "^1.59.1",
"@tailwindcss/postcss": "^4",
"@testcontainers/postgresql": "^11.14.0",
"@types/node": "^25",
Expand Down
65 changes: 65 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { defineConfig, devices } from "@playwright/test";

/**
* Playwright configuration for the HealthLog E2E suite.
*
* The suite covers the smoke-level user paths (auth redirect, login
* form, public version endpoint, locale switch, axe-core) without
* needing seeded data — every spec either runs against an unauthed
* surface or uses route interception to stub out the API. Specs that
* need a logged-in user are kept narrow and flagged in their describe
* block; CI runs them against a worker that seeds a deterministic test
* user on startup.
*
* To run locally: `pnpm dlx playwright install --with-deps chromium`
* once, then `pnpm e2e`.
*/
export default defineConfig({
testDir: "./e2e",
timeout: 30_000,
expect: { timeout: 5_000 },
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: process.env.CI ? 2 : undefined,
reporter: process.env.CI
? [["github"], ["html", { open: "never" }]]
: [["list"], ["html", { open: "never" }]],

use: {
baseURL: process.env.E2E_BASE_URL ?? "http://localhost:3000",
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "retain-on-failure",
},

projects: [
{
name: "chromium-desktop",
use: {
...devices["Desktop Chrome"],
viewport: { width: 1280, height: 720 },
},
},
{
name: "chromium-mobile",
use: {
...devices["iPhone 13"],
},
},
],

// Spin up the production build for E2E. `pnpm build` is run
// separately by CI before the suite — locally, set E2E_SKIP_WEB_SERVER=1
// to point at an already-running dev server.
webServer: process.env.E2E_SKIP_WEB_SERVER
? undefined
: {
command: "pnpm exec next start --port 3000",
url: "http://localhost:3000/api/version",
timeout: 60_000,
reuseExistingServer: !process.env.CI,
stdout: "ignore",
stderr: "pipe",
},
});
Loading
Loading