test(pwa): E2E für Offline-Navigation, -Toast, -Indikator
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 28s
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 28s
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) <noreply@anthropic.com>
This commit is contained in:
122
tests/e2e/offline.spec.ts
Normal file
122
tests/e2e/offline.spec.ts
Normal file
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user