Wipes e2e-* apps and e2e* admin users before the suite starts so a prior crashed run doesn't accumulate state across runs (45 rows observed on 2026-05-28). Per-row try/catch keeps it best-effort; a sweep failure never blocks the suite. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
147 lines
4.8 KiB
TypeScript
147 lines
4.8 KiB
TypeScript
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 sweepOrphans(token);
|
|
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();
|
|
}
|
|
}
|
|
|
|
// Clean up apps + admin users left over from a previous crashed run.
|
|
// The convention is that every e2e-created resource has a slug
|
|
// starting with `e2e-` (apps) or a username starting with `e2e`
|
|
// (admins) — see fixtures/ids.ts. Best-effort: a sweep failure must
|
|
// not stop the suite from running.
|
|
async function sweepOrphans(token: string): Promise<void> {
|
|
const ctx = await request.newContext({
|
|
baseURL: API_BASE,
|
|
extraHTTPHeaders: { authorization: `Bearer ${token}` }
|
|
});
|
|
try {
|
|
try {
|
|
const res = await ctx.get('/api/v1/admin/apps');
|
|
if (res.ok()) {
|
|
const apps = (await res.json()) as Array<{ slug: string }>;
|
|
for (const app of apps) {
|
|
if (!app.slug.startsWith('e2e-')) continue;
|
|
try {
|
|
await ctx.delete(
|
|
`/api/v1/admin/apps/${encodeURIComponent(app.slug)}?force=true`
|
|
);
|
|
} catch {
|
|
// Individual delete failure is non-fatal — the per-test
|
|
// cleanup will catch it on the next run.
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// Listing failed; nothing to do but proceed.
|
|
}
|
|
try {
|
|
const res = await ctx.get('/api/v1/admin/admins');
|
|
if (res.ok()) {
|
|
const admins = (await res.json()) as Array<{ id: string; username: string }>;
|
|
for (const a of admins) {
|
|
if (!/^e2e/.test(a.username)) continue;
|
|
try {
|
|
await ctx.delete(`/api/v1/admin/admins/${a.id}`);
|
|
} catch {
|
|
// Same per-row tolerance as above.
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// Listing failed; same as above.
|
|
}
|
|
} 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();
|
|
}
|
|
}
|