mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-20 09:33:28 +00:00
c8601a2031
* test(e2e): Playwright smoke test infrastructure + 2 dashboard tests (TASK-689) Option A of TASK-689: land the test infrastructure and a minimal smoke test on both mobile and desktop viewports. Broader flow coverage (board view drag, item detail, comments, mobile hamburger, BottomSheet regression guard) is tracked as TASK-733. Infrastructure -------------- - web/playwright.config.ts: two projects (desktop-chromium, mobile- chromium via Pixel 7), reporter list+html, trace/video/screenshot retained on failure, webServer that wipes + recreates the data dir then runs the pad binary. Paths anchored to the config file's directory so runs are cwd-invariant. - web/e2e/global-setup.ts: bootstraps admin via POST /auth/bootstrap, logs in, creates the e2e workspace, mints a user-scoped API token, and persists the token + resolved admin username to fixture.json. - web/e2e/fixtures.ts: extends base test so every BrowserContext automatically gets Authorization: Bearer <token>. Uses a token rather than a session cookie because sessions are User-Agent bound in middleware_auth.go and a node-minted session would be rejected by a Chromium UA. Tests ----- - web/e2e/dashboard.spec.ts: a logged-in user lands on the seeded workspace, no login form is rendered, and the workspace name appears on the page. Runs in both project viewports. CI -- - New `e2e` job in .github/workflows/ci.yml: builds web UI + binary, installs Playwright chromium with OS deps, runs the suite, and uploads the HTML report as an artifact on failure. Timeout capped at 10 minutes (suite itself runs in ~4s today). Local run (in mcr.microsoft.com/playwright:v1.59.1-noble): 2 passed in 4.1s. Parent: PLAN-644. Follow-up: TASK-733 for broader flow coverage (Option B in the original ship plan). * fix(e2e): persist server-returned workspace slug instead of the constant (TASK-689) Addresses Codex P2 on PR #225. When Playwright's `reuseExistingServer: true` (local dev), a re-run of globalSetup hits `POST /api/v1/workspaces` against a DB that already has `e2e`. The server uniquifies the slug (`e2e` → `e2e-2` → …) and returns the uniquified value, but the old code wrote `WORKSPACE_SLUG` (the constant) to fixture.json. Tests then navigated to /e2e-admin/e2e — which might still exist from a previous run with stale state — instead of /e2e-admin/e2e-2, missing regressions in freshly-seeded content. Fix: read `slug` back from the workspace-create response and use that when writing fixture.json. Local re-runs now always point at the workspace this run actually created. Parent: PLAN-644. * fix(e2e): cross-platform webServer bootstrap via Node wrapper (TASK-689) Addresses Codex P2 on PR #225: `rm -rf && mkdir -p && pad server start` in webServer.command is POSIX-only. Windows contributors on cmd.exe or PowerShell can't run `npm run test:e2e` at all — the e2e suite becomes Linux/macOS-only, defeating the "CI parity" goal. Fix: extract the wipe-and-exec logic into web/e2e/run-pad.mjs. Node's fs.rmSync / mkdirSync / child_process.spawn are uniform across platforms, and the wrapper forwards SIGTERM/SIGINT so Playwright's teardown still cleanly kills the child on suite exit. Local re-run in mcr.microsoft.com/playwright:v1.59.1-noble: 2 passed. Parent: PLAN-644.
155 lines
6.1 KiB
TypeScript
155 lines
6.1 KiB
TypeScript
import { request, type FullConfig } from '@playwright/test';
|
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
import { resolve, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
|
|
/**
|
|
* One-shot setup that runs before any spec in the suite.
|
|
*
|
|
* By the time this runs, Playwright's webServer has already started
|
|
* the Pad binary against a *fresh* data dir (the wipe happens in the
|
|
* webServer command, so it beats migrations, not the other way
|
|
* around). This function owns authentication + content seeding:
|
|
*
|
|
* 1. Bootstrap the first admin account via POST /api/v1/auth/bootstrap
|
|
* (only reachable from loopback — matches our test topology).
|
|
* 2. Log in to collect the session cookie + CSRF double-submit pair.
|
|
* 3. Mint a user-scoped API token via POST /api/v1/auth/tokens.
|
|
* 4. Create a starter workspace ("e2e") seeded with the startup
|
|
* template so the dashboard has something to render.
|
|
* 5. Persist the token (not the session cookie) to fixture.json so
|
|
* specs can inject it as a Bearer header.
|
|
*
|
|
* Why the token and not the session cookie:
|
|
* Sessions are User-Agent-bound (see internal/server/middleware_auth.go:
|
|
* "Session binding: validate User-Agent hasn't changed"). A session
|
|
* minted by node.js would be silently rejected by the browser because
|
|
* the UA hash wouldn't match. API tokens bypass SessionAuth entirely
|
|
* via the TokenAuth middleware, which doesn't do UA binding.
|
|
*/
|
|
|
|
export const ADMIN_EMAIL = 'e2e-admin@example.com';
|
|
export const ADMIN_NAME = 'E2E Admin';
|
|
// Must satisfy validatePasswordStrength (see internal/server/handlers_auth.go).
|
|
export const ADMIN_PASSWORD = 'Playwright-Pad-2026!';
|
|
|
|
export const WORKSPACE_SLUG = 'e2e';
|
|
export const WORKSPACE_NAME = 'E2E Workspace';
|
|
|
|
export default async function globalSetup(config: FullConfig) {
|
|
const baseURL = config.projects[0]?.use?.baseURL;
|
|
if (!baseURL) throw new Error('globalSetup: baseURL missing from Playwright config');
|
|
|
|
const api = await request.newContext({ baseURL });
|
|
await waitForHealth(api, baseURL);
|
|
|
|
// Bootstrap the admin. 409 means the instance is already initialized,
|
|
// which is fine for re-runs (reuseExistingServer in local dev).
|
|
const bootstrap = await api.post('/api/v1/auth/bootstrap', {
|
|
data: { email: ADMIN_EMAIL, name: ADMIN_NAME, password: ADMIN_PASSWORD }
|
|
});
|
|
if (!bootstrap.ok() && bootstrap.status() !== 409) {
|
|
throw new Error(`bootstrap failed (${bootstrap.status()}): ${await bootstrap.text()}`);
|
|
}
|
|
|
|
const login = await api.post('/api/v1/auth/login', {
|
|
data: { email: ADMIN_EMAIL, password: ADMIN_PASSWORD }
|
|
});
|
|
if (!login.ok()) {
|
|
throw new Error(`login failed (${login.status()}): ${await login.text()}`);
|
|
}
|
|
|
|
// State-changing endpoints require the CSRF double-submit header
|
|
// matching the pad_csrf cookie issued at login.
|
|
const state = await api.storageState();
|
|
const csrfCookie = state.cookies.find((c) => c.name === 'pad_csrf' || c.name === '__Host-pad_csrf');
|
|
if (!csrfCookie) {
|
|
throw new Error('login succeeded but no pad_csrf cookie was issued');
|
|
}
|
|
const csrfHeader = { 'X-CSRF-Token': csrfCookie.value };
|
|
|
|
// Seed the workspace FIRST. The api_tokens table requires a
|
|
// non-null workspace_id, so the token must be scoped to something.
|
|
// `startup` is the default template and seeds tasks/ideas/plans/docs
|
|
// collections so the dashboard has content.
|
|
const workspaces = await api.post('/api/v1/workspaces', {
|
|
headers: csrfHeader,
|
|
data: { name: WORKSPACE_NAME, slug: WORKSPACE_SLUG, template: 'startup' }
|
|
});
|
|
if (!workspaces.ok() && workspaces.status() !== 409) {
|
|
throw new Error(`workspace create failed (${workspaces.status()}): ${await workspaces.text()}`);
|
|
}
|
|
// Read back the actual slug. The server uniquifies slugs on collision
|
|
// (e2e → e2e-2 → ...), so in the `reuseExistingServer: true` local path
|
|
// a rerun can land on a newly-created `e2e-2` workspace while we still
|
|
// have a constant `WORKSPACE_SLUG` in memory. Trusting the API response
|
|
// keeps fixture.json pointed at whatever was just created.
|
|
const workspace = (await workspaces.json()) as { id?: string; slug?: string };
|
|
if (!workspace.id || !workspace.slug) {
|
|
throw new Error('workspace create response missing id/slug');
|
|
}
|
|
const resolvedSlug = workspace.slug;
|
|
|
|
// Mint an API token scoped to the admin + workspace. Sessions are
|
|
// UA-bound; tokens aren't — the browser can auth with this token
|
|
// regardless of its UA.
|
|
const tokenResp = await api.post('/api/v1/auth/tokens', {
|
|
headers: csrfHeader,
|
|
data: { name: 'e2e-suite', workspace_id: workspace.id, expires_in: 1 }
|
|
});
|
|
if (!tokenResp.ok()) {
|
|
throw new Error(`token create failed (${tokenResp.status()}): ${await tokenResp.text()}`);
|
|
}
|
|
const tokenPayload = (await tokenResp.json()) as { token?: string };
|
|
if (!tokenPayload.token) {
|
|
throw new Error('token create succeeded but response missing token');
|
|
}
|
|
|
|
// Fetch the admin's actual username — auto-generated by the server
|
|
// (see store.GenerateUsername). Web routes are /{username}/{workspace};
|
|
// hardcoding would drift if the algorithm ever changes.
|
|
const meResp = await api.get('/api/v1/auth/me');
|
|
if (!meResp.ok()) {
|
|
throw new Error(`GET /auth/me failed (${meResp.status()}): ${await meResp.text()}`);
|
|
}
|
|
const me = (await meResp.json()) as { username?: string };
|
|
if (!me.username) {
|
|
throw new Error('admin user has no username after bootstrap');
|
|
}
|
|
|
|
await mkdir(resolve(HERE, '.auth'), { recursive: true });
|
|
await writeFile(
|
|
resolve(HERE, '.auth', 'fixture.json'),
|
|
JSON.stringify(
|
|
{
|
|
baseURL,
|
|
workspaceSlug: resolvedSlug,
|
|
adminEmail: ADMIN_EMAIL,
|
|
adminUsername: me.username,
|
|
apiToken: tokenPayload.token
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
);
|
|
}
|
|
|
|
async function waitForHealth(api: Awaited<ReturnType<typeof request.newContext>>, baseURL: string) {
|
|
const deadline = Date.now() + 20_000;
|
|
let lastError: unknown;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const r = await api.get('/api/v1/health');
|
|
if (r.ok()) return;
|
|
} catch (err) {
|
|
lastError = err;
|
|
}
|
|
await new Promise((res) => setTimeout(res, 250));
|
|
}
|
|
throw new Error(
|
|
`server at ${baseURL}/api/v1/health never became ready (${String(lastError ?? 'no error')})`
|
|
);
|
|
}
|