storage-purge failed roughly one run in three on two unrelated races, both of
which blamed whatever change happened to be in flight.
`page.goto('/admin')` rejected with "interrupted by another navigation" or
ERR_ABORTED when the admin layout redirected to /admin/login first — i.e. the
test went red precisely when the app did the right thing, quickly. The
assertion is the waitForURL that follows, which does not care how the
navigation ended, so the goto is now allowed to reject. (waitUntil: 'commit'
narrows the window but an abort can beat commit too.)
And the PIN test read the page's execution context while the layout's boot
hydration was still in flight, which surfaced as an intermittent "Execution
context was destroyed". Settles the page first.
Verified with 50 consecutive runs, previously ~1 in 3 red.
124 lines
4.9 KiB
TypeScript
124 lines
4.9 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);
|
|
|
|
// The `goto` is deliberately allowed to REJECT. What is under test is a race the app wins:
|
|
// the admin layout redirects to /admin/login the moment it sees no JWT, and a redirect that
|
|
// lands first makes this navigation either "interrupted by another navigation" or
|
|
// ERR_ABORTED. Both mean the app did exactly the right thing, quickly — so failing on them
|
|
// made the test red precisely when the behaviour was correct, roughly one run in three, and
|
|
// it read as a regression in whatever change happened to be in flight. (`waitUntil: 'commit'`
|
|
// narrows the window but does not close it; an abort can beat commit too.)
|
|
//
|
|
// `waitForURL` below is the assertion, and it is unaffected by how the navigation ended.
|
|
await page.goto('/admin').catch(() => {});
|
|
// 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');
|
|
// Let the page finish settling before touching its execution context. `goto` resolves at
|
|
// `load`, but the layout's boot hydration is still in flight and any navigation it triggers
|
|
// destroys the context out from under the `page.evaluate` below — which surfaced as an
|
|
// intermittent "Execution context was destroyed" that has nothing to do with what this test
|
|
// asserts.
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
// 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);
|
|
});
|
|
});
|