import { test as base, expect, request as apiRequest, type Page } from '@playwright/test'; // Seed-Fixture: die Tests brauchen mindestens ein Rezept in der DB, // sonst gibt es nichts zu cachen/navigieren. Beim ersten Worker-Run // schauen wir in /api/recipes/all nach — wenn leer, legen wir ein // leeres Rezept per /api/recipes/blank an. // // Außerdem stellen wir sicher, dass ein Profil existiert (nötig für // den Favorit-Button-Test). Das Profil-ID wird als Fixture-Wert // weitergegeben, damit die Tests es in localStorage setzen können. const test = base.extend<{ profileId: number }, { seeded: void; workerProfileId: number }>({ seeded: [ async ({}, use) => { const ctx = await apiRequest.newContext({ baseURL: 'http://localhost:4173' }); try { const res = await ctx.get('/api/recipes/all?sort=name&limit=1&offset=0'); const body = await res.json(); if (body.hits.length === 0) { await ctx.post('/api/recipes/blank'); } } finally { await ctx.dispose(); } await use(); }, { scope: 'worker', auto: true } ], workerProfileId: [ async ({}, use) => { const ctx = await apiRequest.newContext({ baseURL: 'http://localhost:4173' }); let id: number; try { const listRes = await ctx.get('/api/profiles'); const profiles = await listRes.json(); if (profiles.length > 0) { id = profiles[0].id; } else { const createRes = await ctx.post('/api/profiles', { data: { name: 'Test', avatar_emoji: null } }); const p = await createRes.json(); id = p.id; } } finally { await ctx.dispose(); } await use(id); }, { scope: 'worker', auto: false } ], // Test-scoped Alias — wird von Tests direkt per Destrukturierung genutzt profileId: async ({ workerProfileId }, use) => { await use(workerProfileId); } }); // Wartet, bis der Service Worker aktiv ist und der initiale Sync // wahrscheinlich durchgelaufen ist. Wir pollen den Status. async function waitForSync(page: Page) { await page.waitForFunction( async () => { const r = await navigator.serviceWorker.ready; return !!r.active; }, null, { timeout: 10_000 } ); // Heuristik: 3 s reichen für den Pre-Cache eines einzelnen Seed-Rezepts. // Falls flaky, auf 5000 erhöhen oder .pill.syncing wegwarten. await page.waitForTimeout(3000); } test('offline navigation zeigt Rezept-Detail aus dem Cache', async ({ page, context }) => { await page.goto('/'); await waitForSync(page); // Einen existierenden Rezept-Link finden — Seed-Fixture garantiert mindestens einen. await page.goto('/recipes'); const firstLink = page.locator('a[href^="/recipes/"]').first(); const href = await firstLink.getAttribute('href'); expect(href).toBeTruthy(); await context.setOffline(true); await page.goto(href!); await expect(page.locator('h1')).toBeVisible(); }); test('Offline-Schreib-Aktion zeigt Toast', async ({ page, context, profileId }) => { // Profil-ID vor dem ersten Navigieren setzen, damit profileStore.load() // das Profil aus localStorage liest und active != null ist. await page.addInitScript((id: number) => { localStorage.setItem('kochwas.activeProfileId', String(id)); }, profileId); await page.goto('/'); await waitForSync(page); // Rezept-Detail-Seite vorab besuchen, damit der SW sie cacht. await page.goto('/recipes'); const firstLink = page.locator('a[href^="/recipes/"]').first(); const href = await firstLink.getAttribute('href'); await page.goto(href!); // Kurz warten damit die Detail-Seite im SW-Cache landet. await page.waitForTimeout(500); await context.setOffline(true); // Neu navigieren zur gecachten Detail-Seite — SW liefert aus dem Cache. await page.goto(href!, { waitUntil: 'commit' }); await expect(page.locator('h1')).toBeVisible(); await page.getByRole('button', { name: /Favorit/ }).first().click(); await expect(page.locator('.toast.error')).toContainText(/Internet-Verbindung/); }); test('SyncIndicator zeigt Offline-Status', async ({ page, context }) => { await page.goto('/'); await waitForSync(page); // Kein Reload nötig: network.svelte.ts lauscht auf den 'offline'-Browser- // Event, der sofort feuert wenn context.setOffline(true) gesetzt wird. await context.setOffline(true); await expect(page.locator('.wrap .pill.offline')).toContainText('Offline'); });