All checks were successful
screenText sammelt Ansagen per MutationObserver und vergleicht sie; wartet auf leere Region mit registrierter Ansage bzw. 400 ms Ruhe. Unload-Skript mit TCP-Schutz, Doku-Praezisierungen. Refs #3 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
183 lines
8.5 KiB
TypeScript
183 lines
8.5 KiB
TypeScript
import { expect, test } from '@playwright/test';
|
|
import { GoTrueClient, randomEmail, randomPassword, waitMailInterval } from '../../support/gotrue';
|
|
import { expectMailCount, waitForMail } from '../../support/mailpit';
|
|
import { freshBrowserContext, fillField, login, open, register, t, textOf } from '../../support/web';
|
|
|
|
const gotrue = new GoTrueClient();
|
|
|
|
type Page = import('@playwright/test').Page;
|
|
|
|
/**
|
|
* Sammelt ab dem naechsten Seitenladen jede Ansage der Live-Regionen (`flt-announcement-polite/-assertive`)
|
|
* in `window.__announcements` (nur nichtleerer, getrimmter Text; Flutter haengt abwechselnd ein Leerzeichen an,
|
|
* damit gleiche Ansagen erneut vorgelesen werden). Vor `register`/`login` der Seite aufrufen.
|
|
*/
|
|
async function trackAnnouncements(page: Page): Promise<void> {
|
|
await page.addInitScript(() => {
|
|
const w = window as any;
|
|
w.__announcements = [] as { text: string; path: string }[];
|
|
w.__annLastChange = performance.now();
|
|
let last = '';
|
|
const read = () =>
|
|
Array.from(document.querySelectorAll('flt-announcement-polite, flt-announcement-assertive'))
|
|
.map((e) => (e.textContent ?? '').trim())
|
|
.join('')
|
|
.trim();
|
|
const start = () => {
|
|
new MutationObserver(() => {
|
|
const cur = read();
|
|
if (cur !== last) w.__annLastChange = performance.now();
|
|
if (cur !== '' && last === '') w.__announcements.push({ text: cur, path: location.pathname });
|
|
last = cur;
|
|
}).observe(document, { subtree: true, childList: true, characterData: true });
|
|
};
|
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
|
|
else start();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Inhalt des Semantics-Baums, ohne die (je Lauf wechselnde) Adresse, PLUS alle gesammelten Ansagen.
|
|
* Die Live-Region ist fuer Screenreader-Nutzer Teil des Bildschirms (Enumerationsschutz): Ansagen fuer neue
|
|
* und vorhandene Adresse muessen gleich sein. Flutter setzt eine Ansage zeitversetzt und leert sie nach 300 ms;
|
|
* deshalb zuerst warten, bis die Region leer ist UND entweder mindestens eine Ansage registriert wurde oder die
|
|
* Region 400 ms am Stueck leer war (Seite ohne Ansage). Erst dann der Schnappschuss.
|
|
*/
|
|
async function screenText(page: Page): Promise<string> {
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() => {
|
|
const w = window as any;
|
|
const empty =
|
|
Array.from(document.querySelectorAll('flt-announcement-polite, flt-announcement-assertive'))
|
|
.map((e) => (e.textContent ?? '').trim())
|
|
.join('') === '';
|
|
return empty && (w.__announcements.some((a: any) => a.path === location.pathname) || performance.now() - w.__annLastChange >= 400);
|
|
}),
|
|
)
|
|
.toBe(true);
|
|
// Zaehlerstaende der Eingabefelder ("7/30") werden beim Tippen angesagt und haengen vom getippten Text ab, nicht vom Ergebnis.
|
|
// Nur Ansagen der aktuellen Seite (Pfad): Ansagen waehrend des Ausfuellens (Validierung, Zaehler) gehoeren zur
|
|
// Eingabe, nicht zum Ergebnis der Absendung.
|
|
const path = new URL(page.url()).pathname;
|
|
const announcements = ((await page.evaluate(() => (window as any).__announcements)) as { text: string; path: string }[])
|
|
.filter((a) => a.path === path && !/^\d+\/\d+$/.test(a.text))
|
|
.map((a) => a.text);
|
|
// Eingabefelder (mit der je Lauf wechselnden Adresse) gehoeren nicht zum Vergleich.
|
|
const tree = (await page.locator('body').ariaSnapshot()).replace(/^\s*- textbox.*\n?/gm, '').trim();
|
|
return `${tree}\n--- Ansagen ---\n${JSON.stringify(announcements)}`;
|
|
}
|
|
|
|
test.describe('KTO-01 Kriterium 4 (web): gleicher Bildschirm fuer neue und vorhandene Adresse', () => {
|
|
test('KTO-01 Kriterium 4: Registrierung mit bestehender, bestaetigter Adresse zeigt exakt denselben Bildschirm wie mit neuer Adresse', async ({
|
|
page,
|
|
browser,
|
|
}) => {
|
|
// Neue Adresse.
|
|
await trackAnnouncements(page);
|
|
await register(page, { email: randomEmail(), password: randomPassword() });
|
|
await expect(page).toHaveURL(/\/register\/check-email$/);
|
|
await expect(textOf(page, t.checkEmailTitle)).toBeVisible();
|
|
const fresh = await screenText(page);
|
|
|
|
// Vorhandene, bestaetigte Adresse (nur ueber die API angelegt).
|
|
const existing = randomEmail();
|
|
await gotrue.createConfirmedUser(existing, randomPassword());
|
|
const second = await (await freshBrowserContext(browser)).newPage();
|
|
try {
|
|
await trackAnnouncements(second);
|
|
await register(second, { email: existing, password: randomPassword() });
|
|
await expect(second).toHaveURL(/\/register\/check-email$/);
|
|
await expect(textOf(second, t.checkEmailTitle)).toBeVisible();
|
|
expect(await screenText(second)).toBe(fresh);
|
|
// Es geht keine Mail an das bestehende Konto.
|
|
await expectMailCount(existing, 0);
|
|
} finally {
|
|
await second.context().close();
|
|
}
|
|
});
|
|
});
|
|
|
|
test.describe('KTO-01 Kriterium 6 (web): unbestaetigtes Konto', () => {
|
|
test('KTO-01 Kriterium 6: Login mit richtigem Passwort zeigt Hinweis und Aktion; „erneut senden“ schickt eine zweite Mail', async ({
|
|
page,
|
|
}) => {
|
|
const email = randomEmail();
|
|
const password = randomPassword();
|
|
await gotrue.signUp(email, password);
|
|
await waitForMail(email);
|
|
await waitMailInterval();
|
|
|
|
await login(page, email, password);
|
|
await expect(textOf(page, t.emailNotConfirmed)).toBeVisible();
|
|
await page.getByRole('button', { name: t.resendConfirmation }).click();
|
|
await expect(textOf(page, t.checkEmailResent)).toBeVisible();
|
|
await waitForMail(email, 2);
|
|
});
|
|
});
|
|
|
|
test.describe('KTO-01 Kriterium 7 (web): keine Auskunft ueber Konten, Drosselungshinweis', () => {
|
|
test('KTO-01 Kriterium 7: falsche E-Mail und falsches Passwort zeigen denselben Text', async ({ page, browser }) => {
|
|
const email = randomEmail();
|
|
const password = randomPassword();
|
|
await gotrue.createConfirmedUser(email, password);
|
|
|
|
await trackAnnouncements(page);
|
|
await login(page, email, `${password}-falsch`);
|
|
await expect(textOf(page, t.invalidCredentials)).toBeVisible();
|
|
const wrongPassword = await screenText(page);
|
|
|
|
const ctx = await freshBrowserContext(browser);
|
|
try {
|
|
const other = await ctx.newPage();
|
|
await trackAnnouncements(other);
|
|
await login(other, randomEmail('unknown'), password);
|
|
await expect(textOf(other, t.invalidCredentials)).toBeVisible();
|
|
expect(await screenText(other)).toBe(wrongPassword);
|
|
} finally {
|
|
await ctx.close();
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Die CLI drosselt nie (AUTH-CFG-13/A2), ein echter Test waere nie rot. Die 429-Antwort wird
|
|
* deshalb per `page.route` simuliert; der echte Limiter wird im Projekt `throttle` geprueft.
|
|
* CORS: Die App (localhost:3000) ruft Kong (127.0.0.1:54321) cross-origin auf; die simulierte
|
|
* Antwort braucht deshalb die CORS-Header, sonst sieht die App einen Netzfehler statt 429.
|
|
*/
|
|
test('KTO-01 Kriterium 7: AUTH-CFG-10 bei 429 erscheint der Drosselungshinweis ohne Dauer', async ({ page }) => {
|
|
await page.route('**/auth/v1/token?grant_type=password', async (route) => {
|
|
const request = route.request();
|
|
if (request.method() === 'OPTIONS') return route.fallback();
|
|
await route.fulfill({
|
|
status: 429,
|
|
contentType: 'application/json',
|
|
headers: { 'access-control-allow-origin': request.headers()['origin'] ?? '*' },
|
|
body: JSON.stringify({ code: 429, error_code: 'over_request_rate_limit', msg: 'Request rate limit reached' }),
|
|
});
|
|
});
|
|
await login(page, randomEmail(), randomPassword());
|
|
await expect(textOf(page, t.rateLimited)).toBeVisible();
|
|
await expect(textOf(page, t.invalidCredentials)).toHaveCount(0);
|
|
});
|
|
});
|
|
|
|
test.describe('QUE-06 (web): Ablauf nur per Tastatur', () => {
|
|
test('QUE-06 Kriterium 3: Anmelden nur mit Tab, Eingabe und Enter (E-Mail-Feld per Tab erreicht)', async ({ page }) => {
|
|
const email = randomEmail();
|
|
const password = randomPassword();
|
|
await gotrue.createConfirmedUser(email, password);
|
|
|
|
await open(page, '/login');
|
|
await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toBeVisible();
|
|
// Kein Klick: Tab fokussiert das erste Feld (E-Mail), Tab das zweite (Passwort).
|
|
await page.keyboard.press('Tab');
|
|
await expect(page.getByRole('textbox', { name: 'Email address' })).toBeFocused();
|
|
await page.keyboard.type(email);
|
|
await page.keyboard.press('Tab');
|
|
await expect(page.getByRole('textbox', { name: 'Password' })).toBeFocused();
|
|
await page.keyboard.type(password);
|
|
await page.keyboard.press('Enter');
|
|
await expect(textOf(page, t.home)).toBeVisible();
|
|
});
|
|
});
|