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:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -30,6 +30,13 @@ config.local.toml
|
|||||||
/dashboard/build
|
/dashboard/build
|
||||||
/dashboard/.env
|
/dashboard/.env
|
||||||
|
|
||||||
|
# Dashboard — Playwright E2E
|
||||||
|
/dashboard/tests/e2e/.auth
|
||||||
|
/dashboard/tests/e2e/.results
|
||||||
|
/dashboard/playwright-report
|
||||||
|
/dashboard/test-results
|
||||||
|
/dashboard/.playwright
|
||||||
|
|
||||||
# Caddy
|
# Caddy
|
||||||
/caddy/data
|
/caddy/data
|
||||||
/caddy/config
|
/caddy/config
|
||||||
|
|||||||
@@ -2,3 +2,9 @@
|
|||||||
build
|
build
|
||||||
node_modules
|
node_modules
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
|
||||||
|
# Playwright generated artifacts
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
|
tests/e2e/.auth
|
||||||
|
tests/e2e/.results
|
||||||
|
|||||||
64
dashboard/package-lock.json
generated
64
dashboard/package-lock.json
generated
@@ -20,6 +20,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.18.0",
|
"@eslint/js": "^9.18.0",
|
||||||
|
"@playwright/test": "^1.60.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.8",
|
"@sveltejs/adapter-static": "^3.0.8",
|
||||||
"@sveltejs/kit": "^2.17.0",
|
"@sveltejs/kit": "^2.17.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||||
@@ -885,6 +886,22 @@
|
|||||||
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
|
||||||
|
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.60.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@polka/url": {
|
"node_modules/@polka/url": {
|
||||||
"version": "1.0.0-next.29",
|
"version": "1.0.0-next.29",
|
||||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
||||||
@@ -3010,6 +3027,53 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||||
|
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.60.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||||
|
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.15",
|
"version": "8.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||||
|
|||||||
@@ -11,10 +11,14 @@
|
|||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"lint": "prettier --check . && eslint .",
|
"lint": "prettier --check . && eslint .",
|
||||||
"test": "vitest run"
|
"test": "vitest run",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:ui": "playwright test --ui",
|
||||||
|
"test:e2e:install": "playwright install --with-deps chromium"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.18.0",
|
"@eslint/js": "^9.18.0",
|
||||||
|
"@playwright/test": "^1.60.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.8",
|
"@sveltejs/adapter-static": "^3.0.8",
|
||||||
"@sveltejs/kit": "^2.17.0",
|
"@sveltejs/kit": "^2.17.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||||
|
|||||||
48
dashboard/playwright.config.ts
Normal file
48
dashboard/playwright.config.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
const DASHBOARD_PORT = Number(process.env.PICLOUD_DASHBOARD_PORT ?? 5173);
|
||||||
|
// baseURL is the origin only — the SvelteKit dashboard is mounted at
|
||||||
|
// `/admin` (svelte.config.js paths.base), so tests use full paths like
|
||||||
|
// `/admin/login` rather than relying on baseURL path resolution.
|
||||||
|
const DASHBOARD_BASE = process.env.E2E_BASE_URL ?? `http://localhost:${DASHBOARD_PORT}`;
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests/e2e',
|
||||||
|
outputDir: './tests/e2e/.results',
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 1 : 0,
|
||||||
|
workers: process.env.CI ? 2 : undefined,
|
||||||
|
reporter: process.env.CI ? [['html'], ['github']] : 'html',
|
||||||
|
globalSetup: './tests/e2e/global-setup.ts',
|
||||||
|
expect: { timeout: 5_000 },
|
||||||
|
use: {
|
||||||
|
baseURL: DASHBOARD_BASE,
|
||||||
|
actionTimeout: 10_000,
|
||||||
|
navigationTimeout: 15_000,
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
video: 'retain-on-failure'
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: {
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
storageState: path.join(__dirname, 'tests/e2e/.auth/admin.json')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
webServer: {
|
||||||
|
command: 'npm run dev',
|
||||||
|
url: `http://localhost:${DASHBOARD_PORT}/admin/`,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
stdout: 'pipe',
|
||||||
|
stderr: 'pipe',
|
||||||
|
timeout: 60_000
|
||||||
|
}
|
||||||
|
});
|
||||||
75
dashboard/tests/e2e/README.md
Normal file
75
dashboard/tests/e2e/README.md
Normal 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
|
||||||
47
dashboard/tests/e2e/fixtures/api.ts
Normal file
47
dashboard/tests/e2e/fixtures/api.ts
Normal 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'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
21
dashboard/tests/e2e/fixtures/auth.ts
Normal file
21
dashboard/tests/e2e/fixtures/auth.ts
Normal 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$/);
|
||||||
|
}
|
||||||
48
dashboard/tests/e2e/fixtures/cleanup.ts
Normal file
48
dashboard/tests/e2e/fixtures/cleanup.ts
Normal 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 = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
dashboard/tests/e2e/fixtures/ids.ts
Normal file
42
dashboard/tests/e2e/fixtures/ids.ts
Normal 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';
|
||||||
94
dashboard/tests/e2e/global-setup.ts
Normal file
94
dashboard/tests/e2e/global-setup.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
28
dashboard/tests/e2e/smoke.spec.ts
Normal file
28
dashboard/tests/e2e/smoke.spec.ts
Normal 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$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user