The Playwright suite had no linter and no formatter — only tsc. Add flat-config ESLint
(typescript-eslint, type-aware) and Prettier (2-space, matching the suite's style).
Rules keep the ones that catch real TEST bugs and drop the noise:
- no-floating-promises KEPT — an un-awaited request/assertion can let a test end before it runs,
passing vacuously. It caught one: the SSE reader loop in sse-listener is now explicitly `void`.
- no-unused-vars KEPT — caught three dead bindings (an unused adminToken fixture arg, an unused
`api` arg, an unused JPEG_MAGIC import), all removed.
- no-explicit-any OFF — all test code; `any` is the honest type for an untyped res.json() body or
a page.evaluate() return.
- no-empty-pattern OFF — Playwright's dependency-free fixtures are `async ({}, use) => {}`.
Refactor: `const BASE = process.env.E2E_FRONTEND_URL ?? '...'` was redeclared verbatim in 23
files — extracted to helpers/env.ts and imported, so a port/scheme change is one edit not a sweep.
Then `prettier --write`. Verified: eslint clean, tsc clean, prettier clean, desktop suite 210
passed / 1 skipped. (One mobile spec flaked once under retries:0 — a pre-existing cross-test
reflow-timing vector from the flakiness audit, not this change: the each-key edit is stable across
16 isolated runs and a clean full mobile re-run.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
109 lines
3.7 KiB
TypeScript
109 lines
3.7 KiB
TypeScript
/**
|
|
* Phase 2 browser chaos — what happens when the browser drops state mid-session?
|
|
*
|
|
* Real users: Safari ITP, "Clear browsing data", incognito mode expiring,
|
|
* extensions that wipe storage on tab close. The app must NEVER white-screen
|
|
* or expose other users' data when its own state vanishes.
|
|
*/
|
|
import { test, expect } from '../../fixtures/test';
|
|
import { readStorage, clearLocalStorage, clearAllStorage } from '../../helpers/storage-helpers';
|
|
|
|
test.describe('Browser chaos — storage purge', () => {
|
|
test('localStorage.clear() mid-session → next nav goes to /join, no crash', async ({
|
|
page,
|
|
guest,
|
|
signIn,
|
|
}) => {
|
|
const g = await guest('Purge1');
|
|
await signIn(page, g);
|
|
await page.goto('/feed');
|
|
await expect(page.getByRole('link', { name: 'Galerie' })).toBeVisible();
|
|
|
|
// Listen for any unhandled page errors so a crash is visible.
|
|
const errors: Error[] = [];
|
|
page.on('pageerror', (e) => errors.push(e));
|
|
|
|
await clearLocalStorage(page);
|
|
await page.goto('/feed');
|
|
|
|
// The app may redirect to /join, render an empty feed with a "sign in" prompt, or
|
|
// surface the join screen inline. Any of these is fine — the assertion is "no crash".
|
|
expect(errors.filter((e) => !e.message.includes('AbortError'))).toHaveLength(0);
|
|
|
|
// Eventually the user lands somewhere they can recover from.
|
|
const url = new URL(page.url());
|
|
expect(['/join', '/feed', '/recover', '/']).toContain(url.pathname);
|
|
});
|
|
|
|
test('cookies cleared mid-session — JWT in localStorage still works (no cookie dependency)', async ({
|
|
page,
|
|
guest,
|
|
signIn,
|
|
}) => {
|
|
const g = await guest('Purge2');
|
|
await signIn(page, g);
|
|
await page.goto('/feed');
|
|
|
|
await page.context().clearCookies();
|
|
|
|
// The api.ts client reads from localStorage, not cookies, so a /me/context call should still work.
|
|
const stillAuthed = await page.evaluate(async () => {
|
|
const res = await fetch('/api/v1/me/context', {
|
|
headers: { Authorization: `Bearer ${localStorage.getItem('eventsnap_jwt')}` },
|
|
});
|
|
return res.status;
|
|
});
|
|
expect(stillAuthed).toBe(200);
|
|
});
|
|
|
|
test('sessionStorage cleared has no effect on auth (auth lives in localStorage)', async ({
|
|
page,
|
|
guest,
|
|
signIn,
|
|
}) => {
|
|
const g = await guest('Purge3');
|
|
await signIn(page, g);
|
|
await page.goto('/feed');
|
|
|
|
await page.evaluate(() => sessionStorage.clear());
|
|
await page.reload();
|
|
|
|
const storage = await readStorage(page);
|
|
expect(storage.jwt).toBeTruthy(); // localStorage survived
|
|
await expect(page.getByRole('link', { name: 'Galerie' })).toBeVisible();
|
|
});
|
|
|
|
test('clearAllStorage on /admin forces re-login', async ({ page, api }) => {
|
|
const adminJwt = await api.adminLogin();
|
|
await page.goto('/');
|
|
await page.evaluate((j) => localStorage.setItem('eventsnap_jwt', j), adminJwt);
|
|
await page.goto('/admin');
|
|
|
|
await clearAllStorage(page);
|
|
|
|
await page.goto('/admin');
|
|
// The admin layout should bounce them to /admin/login when the JWT is gone.
|
|
await page.waitForURL(/admin\/login|join/, { timeout: 5_000 });
|
|
});
|
|
|
|
test('PIN survives clearAuth (intentional per auth.ts comment)', async ({
|
|
page,
|
|
guest,
|
|
signIn,
|
|
}) => {
|
|
const g = await guest('PurgePin');
|
|
await signIn(page, g);
|
|
await page.goto('/account');
|
|
|
|
// Simulate clearAuth() — clears JWT + user_id but keeps PIN so the user can recover.
|
|
await page.evaluate(() => {
|
|
localStorage.removeItem('eventsnap_jwt');
|
|
localStorage.removeItem('eventsnap_user_id');
|
|
});
|
|
const remaining = await readStorage(page);
|
|
expect(remaining.jwt).toBeNull();
|
|
expect(remaining.userId).toBeNull();
|
|
expect(remaining.pin).toBe(g.pin);
|
|
});
|
|
});
|