/** * Phase 2 browser chaos — going offline, coming back, and throttled * connections. The app's upload queue is expected to "park" pending * items on offline and resume on reconnect. */ import { test, expect } from '../../fixtures/test'; test.describe('Browser chaos — network', () => { test('offline → reconnect — page does not crash and bottom nav is responsive', async ({ page, guest, signIn }) => { const g = await guest('Offline1'); await signIn(page, g); await page.goto('/feed'); const errors: Error[] = []; page.on('pageerror', (e) => errors.push(e)); await page.context().setOffline(true); // Tap the bottom nav — should not raise unhandled errors. await page.getByRole('link', { name: 'Konto' }).click().catch(() => {}); await page.waitForTimeout(500); await page.context().setOffline(false); // App still functional after coming back. await page.goto('/feed'); await expect(page.getByRole('link', { name: 'Galerie' })).toBeVisible(); expect(errors.filter((e) => !e.message.toLowerCase().includes('fetch'))).toHaveLength(0); }); test('slow 3G simulation — initial nav completes within reasonable bound', async ({ page, guest, signIn }) => { const g = await guest('Slow3g'); // 50ms latency on every request, applied to /api/* only so navigation isn't catastrophic. await page.route('**/api/v1/**', async (route) => { await new Promise((r) => setTimeout(r, 50)); await route.continue(); }); await signIn(page, g); await page.goto('/feed'); await expect(page.getByRole('link', { name: 'Galerie' })).toBeVisible({ timeout: 15_000 }); }); test('intermittent API failures during navigation — UI surfaces an error state', async ({ page, guest, signIn }) => { const g = await guest('FlakyApi'); let count = 0; await page.route('**/api/v1/me/context', async (route) => { count++; if (count % 2 === 1) await route.fulfill({ status: 503, body: 'service unavailable' }); else await route.continue(); }); await signIn(page, g); await page.goto('/feed'); // App should still mount even when /me/context bounces — it's not on the critical render path. await expect(page.getByRole('link', { name: 'Galerie' })).toBeVisible({ timeout: 10_000 }); }); test('429 from server is surfaced (no infinite retry storm)', async ({ page, guest, signIn }) => { const g = await guest('Throttled'); let attempts = 0; // Match /api/v1/feed with or without a query string (the app requests // `/feed?limit=20`), but NOT /api/v1/feed/delta. A plain `**/api/v1/feed` glob // fails to match the query-string URL, so this route never fired before. await page.route(/\/api\/v1\/feed(\?|$)/, async (route) => { attempts++; await route.fulfill({ status: 429, headers: { 'retry-after': '60' }, body: JSON.stringify({ error: 'rate_limited', message: 'Zu viele Anfragen.' }), }); }); await signIn(page, g); await page.goto('/feed'); // First make sure the throttled endpoint was actually hit — otherwise the // stabilize-poll below could settle at attempts=0 (feed request not yet fired) // and pass vacuously without ever exercising the 429 path. await expect.poll(() => attempts, { timeout: 8_000 }).toBeGreaterThanOrEqual(1); // Then wait until the retry count stops climbing instead of sleeping a fixed 3s: // a well-behaved client surfaces the 429 and stops, so the count settles quickly; // a retry storm would keep incrementing and never stabilize (→ this poll times // out and the test fails, which is the outcome we want). let prev = -1; await expect .poll( () => { const stable = attempts === prev; prev = attempts; return stable; }, { timeout: 8_000, intervals: [300] } ) .toBe(true); // Sanity: client did not hammer the endpoint > a few times under throttle. expect(attempts, `retry attempts=${attempts}`).toBeLessThan(15); }); });