mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 16:41:08 +02:00
Smallest possible foundation for the persona-driven visual regression
suite (M5 in docs/plans/mana-mcp-and-personas.md). One flow, two
viewports, one persona — enough to prove the stack end-to-end:
seed-script → mana-auth → API login → cookie injection → web app →
screenshot → disk. Extending is copy-paste per flow.
tests/personas/
playwright.config.ts
Own config separate from the root tests/e2e/ suite. Two viewports
(1440×900 desktop Chrome + Pixel 5 mobile) — more can be added
once baselines settle without quadrupling the review load.
Diff threshold 0.2 %, animations disabled, snapshots land under
__snapshots__/{spec}/{arg}-{project}.png. No auto-webServer —
the whole point is to catch regressions against the real stack
the user runs, not a hermetic one; if the stack is down, tests
fail loud.
fixtures/persona-auth.ts
Typed Playwright `test.extend` with a `personaKey` worker option
and a `personaPage` fixture that returns a pre-logged-in Page
pointed at `/`. Login is API-side: POST /api/v1/auth/login with
the deterministic HMAC-SHA256 password, parse Set-Cookie headers,
inject into the browser context. Derivation is a bit-identical
mirror of scripts/personas/password.ts and
services/mana-persona-runner/src/password.ts — a 3-way contract.
Changing one without the others locks the suite out of every
persona. PERSONAS map exports all 10 catalog emails for typed
access.
flows/home.spec.ts
One smoke flow. Asserts the persona isn't redirected to /login,
hides any [data-testid="live-time"] so clock widgets don't
invalidate diffs, captures a full-page screenshot. When this
goes green, the whole pipeline is plumbed. Copy this file to
add per-module tours.
package.json
@mana/tests-personas workspace. Scripts: `test`, `test:update`,
`report` (HTML diff viewer).
README.md
Prerequisites (stack up + seeded + ideally persona-runner ticked
once), run recipe, env vars, architecture diagram, extension
pattern.
root package.json: `pnpm test:personas` + `:update`.
.gitignore: playwright-report-personas/ + test-results/ so generated
artefacts never get committed.
Type-check / list: `playwright test --list` succeeds, 2 tests (one
per viewport) registered for home.spec.ts.
Not attempted in this commit (user action to run the stack):
- Actual baseline capture (needs docker up + db:push + seed:personas
+ ANTHROPIC_API_KEY + diag/tick).
- Additional flows (todo, journal, notes, habits, calendar). They're
copy-paste per README. Land when the stack is smoked.
- Nightly CI job. Will land once baselines are stable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
171 lines
5.9 KiB
TypeScript
171 lines
5.9 KiB
TypeScript
/**
|
|
* API-login fixture.
|
|
*
|
|
* The web app's normal login flow is a visual concern — we're testing
|
|
* downstream views, not the login UI, so we skip the form. Instead:
|
|
*
|
|
* 1. Derive the persona's password (same HMAC algorithm as the seed
|
|
* script + the persona-runner — all three must stay in sync).
|
|
* 2. POST /api/v1/auth/login to capture the better-auth session cookie.
|
|
* 3. Inject the cookie into the Playwright browser context, then goto /.
|
|
*
|
|
* Tests opt in via the typed `test.use({ persona: 'anna' })` below;
|
|
* they receive a logged-in `page` with `persona.email` available.
|
|
*/
|
|
|
|
import { test as base, expect, type BrowserContext, type Page } from '@playwright/test';
|
|
import { createHmac } from 'node:crypto';
|
|
|
|
const AUTH_URL = process.env.PERSONAS_AUTH_URL ?? 'http://localhost:3001';
|
|
const BASE_URL = process.env.PERSONAS_BASE_URL ?? 'http://localhost:5173';
|
|
const COOKIE_DOMAIN = process.env.PERSONAS_COOKIE_DOMAIN ?? new URL(BASE_URL).hostname;
|
|
const SEED_SECRET = process.env.PERSONA_SEED_SECRET ?? 'dev-persona-seed-secret-rotate-in-prod';
|
|
|
|
/**
|
|
* Must stay bit-identical to `scripts/personas/password.ts` and
|
|
* `services/mana-persona-runner/src/password.ts`. Changing one without
|
|
* the others locks the test suite out of every persona.
|
|
*/
|
|
function personaPassword(email: string): string {
|
|
const hmac = createHmac('sha256', SEED_SECRET).update(email).digest('base64');
|
|
return hmac.replace(/[^a-zA-Z0-9]/g, '').slice(0, 32);
|
|
}
|
|
|
|
export interface PersonaInfo {
|
|
name: string;
|
|
email: string;
|
|
archetype: string;
|
|
}
|
|
|
|
export const PERSONAS: Record<string, PersonaInfo> = {
|
|
anna: { name: 'Anna', email: 'persona.anna@mana.test', archetype: 'adhd-student' },
|
|
ben: { name: 'Ben', email: 'persona.ben@mana.test', archetype: 'adhd-student' },
|
|
marcus: { name: 'Marcus', email: 'persona.marcus@mana.test', archetype: 'ceo-busy' },
|
|
lena: { name: 'Lena', email: 'persona.lena@mana.test', archetype: 'ceo-busy' },
|
|
sofia: { name: 'Sofia', email: 'persona.sofia@mana.test', archetype: 'creative-parent' },
|
|
tom: { name: 'Tom', email: 'persona.tom@mana.test', archetype: 'creative-parent' },
|
|
kai: { name: 'Kai', email: 'persona.kai@mana.test', archetype: 'solo-dev' },
|
|
julia: { name: 'Julia', email: 'persona.julia@mana.test', archetype: 'researcher' },
|
|
paul: { name: 'Paul', email: 'persona.paul@mana.test', archetype: 'freelancer' },
|
|
maya: { name: 'Maya', email: 'persona.maya@mana.test', archetype: 'overwhelmed-newbie' },
|
|
} as const;
|
|
|
|
export type PersonaKey = keyof typeof PERSONAS;
|
|
|
|
interface SetCookie {
|
|
name: string;
|
|
value: string;
|
|
path?: string;
|
|
domain?: string;
|
|
expires?: number;
|
|
httpOnly?: boolean;
|
|
secure?: boolean;
|
|
sameSite?: 'Strict' | 'Lax' | 'None';
|
|
}
|
|
|
|
/**
|
|
* Log in over HTTP, return the Set-Cookie headers as Playwright-compatible
|
|
* cookie objects ready for `context.addCookies()`.
|
|
*/
|
|
async function loginAndGetCookies(email: string): Promise<SetCookie[]> {
|
|
const res = await fetch(`${AUTH_URL}/api/v1/auth/login`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ email, password: personaPassword(email) }),
|
|
});
|
|
if (!res.ok) {
|
|
const text = await res.text().catch(() => '<unreadable>');
|
|
throw new Error(
|
|
`Persona login ${email} failed: HTTP ${res.status} — ${text.slice(0, 300)}. ` +
|
|
`Is mana-auth running at ${AUTH_URL}? Has \`pnpm seed:personas\` been run?`
|
|
);
|
|
}
|
|
const raw =
|
|
res.headers.getSetCookie?.() ?? res.headers.get('set-cookie')?.split(/, (?=[^ ])/) ?? [];
|
|
const cookies: SetCookie[] = [];
|
|
for (const line of raw) {
|
|
const cookie = parseSetCookie(line);
|
|
if (cookie) cookies.push(cookie);
|
|
}
|
|
if (cookies.length === 0) {
|
|
throw new Error(
|
|
`Persona login ${email} succeeded but no Set-Cookie headers — better-auth config drift?`
|
|
);
|
|
}
|
|
return cookies;
|
|
}
|
|
|
|
function parseSetCookie(header: string): SetCookie | null {
|
|
const parts = header.split(';').map((p) => p.trim());
|
|
const [nv, ...rest] = parts;
|
|
const eq = nv.indexOf('=');
|
|
if (eq < 0) return null;
|
|
const cookie: SetCookie = {
|
|
name: nv.slice(0, eq),
|
|
value: nv.slice(eq + 1),
|
|
path: '/',
|
|
domain: COOKIE_DOMAIN,
|
|
};
|
|
for (const attr of rest) {
|
|
const [rk, rv] = attr.split('=', 2);
|
|
const key = rk.toLowerCase();
|
|
if (key === 'path' && rv) cookie.path = rv;
|
|
else if (key === 'domain' && rv) cookie.domain = rv.replace(/^\./, '');
|
|
else if (key === 'httponly') cookie.httpOnly = true;
|
|
else if (key === 'secure') cookie.secure = true;
|
|
else if (key === 'samesite' && rv) {
|
|
const s = rv as 'Strict' | 'Lax' | 'None';
|
|
cookie.sameSite = s;
|
|
}
|
|
}
|
|
return cookie;
|
|
}
|
|
|
|
export async function loginPersonaContext(context: BrowserContext, email: string): Promise<void> {
|
|
const cookies = await loginAndGetCookies(email);
|
|
await context.addCookies(
|
|
cookies.map((c) => ({
|
|
name: c.name,
|
|
value: c.value,
|
|
domain: c.domain ?? COOKIE_DOMAIN,
|
|
path: c.path ?? '/',
|
|
httpOnly: c.httpOnly,
|
|
secure: c.secure,
|
|
sameSite: c.sameSite,
|
|
}))
|
|
);
|
|
}
|
|
|
|
// ─── Typed test fixture ───────────────────────────────────────────
|
|
|
|
type PersonaTestFixtures = {
|
|
persona: PersonaInfo;
|
|
/** Pre-logged-in page pointed at `/`. Waiting for networkidle already done. */
|
|
personaPage: Page;
|
|
};
|
|
|
|
type PersonaWorkerOptions = {
|
|
personaKey: PersonaKey;
|
|
};
|
|
|
|
/**
|
|
* Use `test.extend<{}, PersonaWorkerOptions>` and declare `test.use({ personaKey: 'anna' })`
|
|
* at the top of each spec. Each spec gets a freshly-logged-in page.
|
|
*/
|
|
export const test = base.extend<PersonaTestFixtures, PersonaWorkerOptions>({
|
|
personaKey: ['anna', { option: true, scope: 'worker' }],
|
|
persona: async ({ personaKey }, use) => {
|
|
const info = PERSONAS[personaKey];
|
|
if (!info) throw new Error(`Unknown persona key: ${personaKey}`);
|
|
await use(info);
|
|
},
|
|
personaPage: async ({ context, persona }, use) => {
|
|
await loginPersonaContext(context, persona.email);
|
|
const page = await context.newPage();
|
|
await page.goto('/');
|
|
await page.waitForLoadState('networkidle');
|
|
await use(page);
|
|
},
|
|
});
|
|
|
|
export { expect };
|