Files
kochwas/tests/e2e/offline.spec.ts

123 lines
4.4 KiB
TypeScript
Raw Normal View History

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');
});