From 1a4f7b5f2087612b3ab38f3c11c1d50e15529010 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sat, 18 Apr 2026 17:07:21 +0200 Subject: [PATCH] =?UTF-8?q?test(pwa):=20E2E=20f=C3=BCr=20Offline-Navigatio?= =?UTF-8?q?n,=20-Toast,=20-Indikator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Playwright-Spec mit drei Tests: - Offline-Navigation zu einem Rezept-Detail (Cache-Lesefall) - Schreib-Aktion offline zeigt Toast (Favorit-Klick → Fehler-Toast) - SyncIndicator zeigt "Offline"-Pill bei deaktiviertem Netzwerk Seed-Fixture legt per /api/recipes/blank ein Rezept an, falls die DB leer ist. waitForSync pollt navigator.serviceWorker.ready und wartet zusätzlich 3 s für den initialen Pre-Cache. Profil-Fixture (worker-scoped) erstellt bei Bedarf ein Test-Profil und setzt es per addInitScript in localStorage, damit der Favorit- Button den requireOnline-Guard erreicht (statt alertAction-Dialog). SyncIndicator-Test ohne Reload: network.svelte.ts lauscht direkt auf den 'offline'-Browser-Event, der bei context.setOffline(true) feuert. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/e2e/offline.spec.ts | 122 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 tests/e2e/offline.spec.ts diff --git a/tests/e2e/offline.spec.ts b/tests/e2e/offline.spec.ts new file mode 100644 index 0000000..50b9965 --- /dev/null +++ b/tests/e2e/offline.spec.ts @@ -0,0 +1,122 @@ +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'); +});