/** * Helfer fuer das Projekt `web` (Flutter-Web-Build, Port 3000). * * Flutter zeichnet auf ein Canvas; Playwright sieht Elemente erst, wenn der Semantics-Baum * eingeschaltet ist (Platzhalter `flt-semantics-placeholder`). Locator-Namen stammen aus * `app/lib/l10n/app_en.arb` (Browser-Sprache en-US, siehe playwright.config.ts). */ import { expect, type BrowserContext, type Locator, type Page } from '@playwright/test'; import { env, TERMS_VERSION } from './auth-config'; export { TERMS_VERSION }; /** Texte (app_en.arb). */ export const t = { invalidCredentials: 'Email address or password is incorrect.', emailNotConfirmed: 'Please confirm your email address first. We sent you an email for this.', resendConfirmation: 'Resend confirmation email', rateLimited: 'Too many attempts. Please wait a few minutes and try again.', linkInvalid: 'The link has expired or has already been used.', checkEmailTitle: 'Check your inbox', checkEmailResent: 'We resent the email, if there is something to send for this address.', ageNotConfirmed: 'Shuffle can only be used from age 16.', termsNotAccepted: 'Please agree to the privacy policy and the terms of use.', confirmSuccess: 'Email confirmed.', passwordChanged: 'Password changed. Other devices will be signed out; this can take up to an hour.', home: 'Welcome to Shuffle. The board game catalog is coming soon.', } as const; /** * Sichtbarer Text im Semantics-Baum. Flutter legt Texte teils als Textknoten, teils als * `aria-label` ab (z. B. der Inhalt der CheckEmail-Seite). */ export function textOf(page: Page, text: string | RegExp): Locator { return page.getByText(text).or(page.getByLabel(text)).first(); } /** Schaltet den Semantics-Baum ein (einmal je Seitenladen). */ export async function enableSemantics(page: Page): Promise { const placeholder = page.locator('flt-semantics-placeholder'); await placeholder.waitFor({ state: 'attached' }); await placeholder.evaluate((el: HTMLElement) => el.click()); await page.locator('flt-semantics').first().waitFor({ state: 'attached' }); } /** * Bedient die Schaltflaeche auf Seiten ohne Eingabefeld (Link-Seite, CheckEmail) per Tastatur. * * BEFUND (a11y, QUE-06): Auf diesen Seiten faltet der Semantics-Baum Titel, Text und Knopf zu * EINEM `heading` (bzw. `button`) zusammen; ein eigener `button`-Knoten fehlt, ein Klick auf * die Mitte bewirkt nichts. Per Tab + Enter geht es. Wird die App korrigiert, kann der Helfer * auf `getByRole('button', ...)` umgestellt werden. */ export async function pressFirstButton(page: Page, tabs = 1): Promise { for (let i = 0; i < tabs; i++) await page.keyboard.press('Tab'); await page.keyboard.press('Enter'); } /** Navigiert und schaltet Semantics ein. */ export async function open(page: Page, path: string): Promise { await page.goto(path); await enableSemantics(page); } /** * Fuellt ein Flutter-Textfeld. Das Semantics-Textfeld legt das echte erst beim Fokus an; * `fill` allein geht dann gelegentlich verloren. Deshalb klicken, fuellen und den Wert pruefen * (mit Wiederholung). */ export async function fillField(field: Locator, value: string): Promise { await expect(async () => { await field.click(); await field.fill(''); await field.pressSequentially(value); await expect(field).toHaveValue(value, { timeout: 1_000 }); }).toPass({ timeout: 15_000 }); } export async function fillRegistration( page: Page, v: { email: string; password: string; displayName?: string; age?: boolean; terms?: boolean }, ): Promise { await fillField(page.getByRole('textbox', { name: 'Email address' }), v.email); await fillField(page.getByRole('textbox', { name: 'Password' }), v.password); await fillField(page.getByRole('textbox', { name: 'Display name' }), v.displayName ?? 'E2E Tester'); if (v.age ?? true) await page.getByRole('checkbox', { name: /at least 16 years old/ }).click(); if (v.terms ?? true) await page.getByRole('checkbox', { name: /privacy policy/ }).click(); } export async function register(page: Page, v: Parameters[1]): Promise { await open(page, '/register'); await fillRegistration(page, v); await page.getByRole('button', { name: 'Create account' }).click(); } export async function login(page: Page, email: string, password: string): Promise { await open(page, '/login'); await fillField(page.getByRole('textbox', { name: 'Email address' }), email); await fillField(page.getByRole('textbox', { name: 'Password' }), password); await page.getByRole('button', { name: 'Sign in', exact: true }).click(); } export async function expectSignedIn(page: Page): Promise { await expect(page).toHaveURL(/\/$/); await expect(textOf(page, t.home)).toBeVisible(); } /** * Angemeldet <=> `/login` leitet weg (Guest-only-Route, Spec 5.3). Prueft ohne Fixture-Annahmen * ueber den Storage-Key. */ export async function expectSignedInViaLoginRedirect(page: Page): Promise { await page.goto('/login'); await expect(page).toHaveURL(/\/$/); } /** `localStorage`-Eintraege von supabase_flutter im Web (Schluessel nicht raten, auslesen). */ export async function readAuthStorage(page: Page): Promise> { return page.evaluate(() => { const out: Record = {}; for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i)!; if (k.includes('supabase.auth.token') || /sb-.*-auth-token/.test(k)) out[k] = localStorage.getItem(k)!; } return out; }); } /** Neue Seite in einem frischen Kontext mit denselben Browser-Optionen. */ export async function freshContext(context: BrowserContext): Promise { return context.browser()!.newContext({ locale: 'en-US', serviceWorkers: 'block', baseURL: env.webUrl }); } /** * Verschiebt die Browser-Uhr ab dem naechsten Seitenladen um `offsetMs` in die Zukunft. * * Zweck (Krit. 9, F1): Der Access-Token laeuft erst nach jwt_expiry (AUTH-CFG-14, 60 min) ab; der * Test erzwingt den Refresh, ohne zu warten. `Session.expiresAt` in supabase_flutter wird aus dem * JWT-Claim `exp` berechnet, nicht aus dem gespeicherten Feld `expires_at`; das Aendern des * localStorage haette also keine Wirkung. Der Server rechnet weiter mit der echten Zeit. */ export async function shiftBrowserClock(page: Page, offsetMs: number): Promise { await page.addInitScript((offset: number) => { const RealDate = Date; class ShiftedDate extends RealDate { constructor(...args: any[]) { if (args.length === 0) super(RealDate.now() + offset); else super(...(args as [any])); } static now() { return RealDate.now() + offset; } } (globalThis as any).Date = ShiftedDate; }, offsetMs); } /** Schluessel der supabase_flutter-Sitzung im localStorage (nicht fest verdrahtet, nicht ueber Index). */ export async function authStorageKey(page: Page): Promise { return page.evaluate(() => { for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i)!; if (/^sb-.*-auth-token$/.test(k)) return k; } return null; }); } /** Aktuelle Sitzung aus dem localStorage oder `null`. */ export async function readSession(page: Page): Promise<{ access_token: string; refresh_token: string } | null> { const key = await authStorageKey(page); if (!key) return null; const raw = await page.evaluate((k) => localStorage.getItem(k), key); return raw ? JSON.parse(raw) : null; } /** Frischer Browser-Kontext mit den Projekt-Optionen (en-US, kein Service Worker, Basis-URL aus der Config). */ export async function freshBrowserContext(browser: import('@playwright/test').Browser): Promise { return browser.newContext({ locale: 'en-US', serviceWorkers: 'block', baseURL: env.webUrl }); }