test(dashboard): add Playwright e2e scaffolding with smoke spec

Milestone A of the frontend test plan. Sets up the test rig — config,
globalSetup that probes the backend and seeds an admin session into
storageState, lightweight fixtures, and a 3-test smoke spec — without
yet covering any user journeys (those land in Milestone B).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-28 07:03:44 +02:00
parent e6fc6e6a0e
commit 74f7b3b631
12 changed files with 531 additions and 47 deletions

View File

@@ -0,0 +1,75 @@
# Dashboard E2E tests
Browser-driven tests for the PiCloud dashboard, powered by [Playwright].
## Prerequisites
The tests drive a real dashboard against a real backend. Bring up both
before running:
```sh
# 1. Postgres
docker compose up -d postgres
# 2. Backend (port 18080 matches dashboard/vite.config.ts dev proxy)
PICLOUD_BIND=127.0.0.1:18080 \
PICLOUD_ADMIN_USERNAME=admin \
PICLOUD_ADMIN_PASSWORD=admin \
DATABASE_URL=postgres://picloud:picloud@127.0.0.1:15432/picloud \
cargo run -p picloud
# 3. Browser binaries (one-time, ~200 MB)
cd dashboard && npm run test:e2e:install
```
The Vite dev server is started automatically by Playwright's `webServer`
config — you do not need to run `npm run dev` yourself.
## Running
```sh
cd dashboard
npm run test:e2e # headless, full suite
npm run test:e2e:ui # interactive UI runner
npx playwright test smoke # run a single spec
npx playwright show-report
```
## Env vars
| Var | Default | Notes |
| ------------------------ | ------------------------ | ----------------------------------------------------------------- |
| `E2E_BASE_URL` | `http://localhost:5173` | Origin tests navigate against (dashboard is mounted at `/admin`). |
| `E2E_API_BASE` | `http://127.0.0.1:18080` | Backend used by globalSetup health probe + admin login. |
| `E2E_DASHBOARD_ORIGIN` | `http://localhost:5173` | Used to seed `localStorage` during globalSetup. |
| `E2E_ADMIN_USERNAME` | `admin` | Bootstrap admin to log in as. |
| `E2E_ADMIN_PASSWORD` | `admin` | Match `PICLOUD_ADMIN_PASSWORD` above. |
| `PICLOUD_DASHBOARD_PORT` | `5173` | Dev server port — picked up by both Vite and Playwright. |
## How isolation works
Tests share one backend + one Postgres. To avoid cross-test interference:
- A shared bootstrap admin session is captured once in
`tests/e2e/.auth/admin.json` (gitignored) and reused by every test via
`storageState`.
- Each test creates resources with a unique slug / username produced by
`fixtures/ids.ts` (`e2e-<prefix>-w<worker>-<random>`).
- Each test registers cleanup via `fixtures/cleanup.ts` and tears down
in `afterEach`. Cleanup is best-effort: a missing resource doesn't
fail the suite, so a test can pre-delete and still register the entry.
## Layout
```
tests/e2e/
global-setup.ts # health probe + admin login + storageState seed
smoke.spec.ts # A.5 smoke
fixtures/
auth.ts # UI login/logout helpers (for login-flow specs)
api.ts # bearer-token-backed APIRequestContext
ids.ts # unique slug/username generators (test-fixture)
cleanup.ts # afterEach resource teardown
```
[Playwright]: https://playwright.dev

View File

@@ -0,0 +1,47 @@
import { request, type APIRequestContext } from '@playwright/test';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080';
const STATE_PATH = path.join(__dirname, '..', '.auth', 'admin.json');
interface StoredState {
origins: Array<{
origin: string;
localStorage: Array<{ name: string; value: string }>;
}>;
}
let cachedToken: string | null = null;
async function readAdminToken(): Promise<string> {
if (cachedToken) return cachedToken;
const raw = await fs.readFile(STATE_PATH, 'utf8');
const state = JSON.parse(raw) as StoredState;
for (const origin of state.origins) {
const entry = origin.localStorage.find((e) => e.name === 'picloud.admin.token');
if (entry) {
cachedToken = entry.value;
return entry.value;
}
}
throw new Error(`No picloud.admin.token in ${STATE_PATH} — did globalSetup run?`);
}
// Thin wrapper around Playwright's request context that injects the
// admin bearer token from the shared storageState. Use this for
// setup/teardown shortcuts when the *test itself* is about something
// else (e.g., a script-editor test that just needs an app to exist).
export async function adminApi(): Promise<APIRequestContext> {
const token = await readAdminToken();
return request.newContext({
baseURL: API_BASE,
extraHTTPHeaders: {
authorization: `Bearer ${token}`,
'content-type': 'application/json'
}
});
}

View File

@@ -0,0 +1,21 @@
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
const ADMIN_USERNAME = process.env.E2E_ADMIN_USERNAME ?? 'admin';
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'admin';
// Drive the login form like a real user. globalSetup already saves a
// storageState for the shared admin, so most tests don't need this —
// it's reserved for specs that explicitly cover the login UI.
export async function loginAsAdmin(page: Page): Promise<void> {
await page.goto('/admin/login');
await page.getByLabel('Username').fill(ADMIN_USERNAME);
await page.getByLabel('Password').fill(ADMIN_PASSWORD);
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page).toHaveURL(/\/admin\/apps$/);
}
export async function logout(page: Page): Promise<void> {
await page.getByRole('button', { name: /logout/i }).click();
await expect(page).toHaveURL(/\/admin\/login$/);
}

View File

@@ -0,0 +1,48 @@
import type { APIRequestContext } from '@playwright/test';
import { adminApi } from './api';
// Resources to delete after a test, in LIFO order. Tests register
// their creations and the registry tears everything down in
// `cleanupRegistered` — typically called from `test.afterEach`.
type Cleanup = (api: APIRequestContext) => Promise<void>;
export class CleanupRegistry {
private items: Cleanup[] = [];
app(slugOrId: string): void {
this.items.push(async (api) => {
await api.delete(`/api/v1/admin/apps/${encodeURIComponent(slugOrId)}?force=true`);
});
}
adminUser(userId: string): void {
this.items.push(async (api) => {
await api.delete(`/api/v1/admin/admins/${userId}`);
});
}
apiKey(keyId: string): void {
this.items.push(async (api) => {
await api.delete(`/api/v1/admin/api-keys/${keyId}`);
});
}
async run(): Promise<void> {
if (this.items.length === 0) return;
const api = await adminApi();
try {
for (const item of this.items.reverse()) {
try {
await item(api);
} catch {
// Best-effort cleanup — a missing resource (already
// deleted by the test) shouldn't fail the suite.
}
}
} finally {
await api.dispose();
this.items = [];
}
}
}

View File

@@ -0,0 +1,42 @@
/* eslint-disable no-empty-pattern -- Playwright fixtures require an
object-pattern first arg; these fixtures don't depend on any other
fixture so the pattern is intentionally empty. */
import { test as base } from '@playwright/test';
import { randomBytes } from 'node:crypto';
// Tests share a single backend/Postgres. To avoid collisions we tag
// every resource the test creates with a short random suffix plus the
// Playwright worker index. This way two workers running the same spec
// in parallel never fight over the same slug or username.
export function shortId(): string {
return randomBytes(3).toString('hex');
}
export function uniqueSlug(prefix: string, workerIndex: number): string {
const cleaned = prefix
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
return `e2e-${cleaned}-w${workerIndex}-${shortId()}`;
}
export function uniqueUsername(prefix: string, workerIndex: number): string {
// Username regex is [a-z0-9._-]{2,32}. Mirror the slug format.
const cleaned = prefix.toLowerCase().replace(/[^a-z0-9]+/g, '');
return `e2e${cleaned}w${workerIndex}${shortId()}`.slice(0, 32);
}
export const test = base.extend<{
uniqueSlug: (prefix: string) => string;
uniqueUsername: (prefix: string) => string;
}>({
uniqueSlug: async ({}, use, testInfo) => {
await use((prefix) => uniqueSlug(prefix, testInfo.workerIndex));
},
uniqueUsername: async ({}, use, testInfo) => {
await use((prefix) => uniqueUsername(prefix, testInfo.workerIndex));
}
});
export { expect } from '@playwright/test';

View File

@@ -0,0 +1,94 @@
import { chromium, request } from '@playwright/test';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080';
const DASHBOARD_PORT = Number(process.env.PICLOUD_DASHBOARD_PORT ?? 5173);
const DASHBOARD_ORIGIN = process.env.E2E_DASHBOARD_ORIGIN ?? `http://localhost:${DASHBOARD_PORT}`;
const ADMIN_USERNAME = process.env.E2E_ADMIN_USERNAME ?? 'admin';
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'admin';
const AUTH_DIR = path.join(__dirname, '.auth');
const ADMIN_STATE_PATH = path.join(AUTH_DIR, 'admin.json');
export default async function globalSetup(): Promise<void> {
await assertBackendUp();
await fs.mkdir(AUTH_DIR, { recursive: true });
const token = await loginAsAdmin();
await persistAdminStorageState(token);
}
async function assertBackendUp(): Promise<void> {
const probe = await request.newContext();
try {
const res = await probe.get(`${API_BASE}/healthz`, { timeout: 5_000 });
if (!res.ok()) {
throw new Error(
`backend /healthz returned ${res.status()} — is \`cargo run -p picloud\` listening on ${API_BASE}?`
);
}
} catch (err) {
throw new Error(
`Could not reach backend at ${API_BASE}/healthz. ` +
`Bring it up before running E2E tests:\n\n` +
` docker compose up -d postgres\n` +
` PICLOUD_BIND=127.0.0.1:18080 \\\n` +
` PICLOUD_ADMIN_USERNAME=${ADMIN_USERNAME} \\\n` +
` PICLOUD_ADMIN_PASSWORD=${ADMIN_PASSWORD} \\\n` +
` DATABASE_URL=postgres://picloud:picloud@127.0.0.1:15432/picloud \\\n` +
` cargo run -p picloud\n\n` +
`Underlying error: ${(err as Error).message}`
);
} finally {
await probe.dispose();
}
}
async function loginAsAdmin(): Promise<string> {
const ctx = await request.newContext();
try {
const res = await ctx.post(`${API_BASE}/api/v1/admin/auth/login`, {
data: { username: ADMIN_USERNAME, password: ADMIN_PASSWORD },
headers: { 'content-type': 'application/json' }
});
if (!res.ok()) {
const body = await res.text();
throw new Error(
`Admin login failed (${res.status()}): ${body}. ` +
`Verify PICLOUD_ADMIN_USERNAME / PICLOUD_ADMIN_PASSWORD match the seeded bootstrap admin.`
);
}
const payload = (await res.json()) as { token?: string };
if (!payload.token) {
throw new Error('Admin login response missing token field');
}
return payload.token;
} finally {
await ctx.dispose();
}
}
// The dashboard reads its session from localStorage under the key
// `picloud.admin.token` (see src/lib/auth.ts). We can't write to
// localStorage without a browser context, so launch a throwaway one,
// seed the value, then save storageState for every test to reuse.
async function persistAdminStorageState(token: string): Promise<void> {
const browser = await chromium.launch();
try {
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(`${DASHBOARD_ORIGIN}/admin/login`);
await page.evaluate(
([key, value]) => {
localStorage.setItem(key, value);
},
['picloud.admin.token', token]
);
await context.storageState({ path: ADMIN_STATE_PATH });
} finally {
await browser.close();
}
}

View File

@@ -0,0 +1,28 @@
import { expect, test } from '@playwright/test';
import { loginAsAdmin } from './fixtures/auth';
// A1 smoke: prove globalSetup + webServer + fixtures + proxy all work.
test.describe('smoke', () => {
test.describe('unauthenticated', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('root redirects to login and shows the form', async ({ page }) => {
await page.goto('/admin/');
await expect(page).toHaveURL(/\/admin\/login$/);
await expect(page.getByLabel('Username')).toBeVisible();
await expect(page.getByLabel('Password')).toBeVisible();
await expect(page.getByRole('button', { name: /sign in/i })).toBeVisible();
});
test('valid credentials land on the apps page', async ({ page }) => {
await loginAsAdmin(page);
await expect(page.getByRole('link', { name: 'Apps' })).toBeVisible();
});
});
test('admin storageState already lands on apps', async ({ page }) => {
await page.goto('/admin/');
await expect(page).toHaveURL(/\/admin\/apps$/);
});
});