/** * FlowDeck — PWA offline E2E (v6.0.0) * * Requires a running FlowDeck server (FD_BASE_URL, default http://localhost:8080) * with local registration enabled. Tests are skipped when the server is down. * * Covers: service worker registration, manifest, offline shell load, * offline mutation queueing (IndexedDB) and replay on reconnect. */ const { test, expect } = require('@playwright/test'); const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080'; const FD_PASS = process.env.FD_PASS || 'e2e-secret-123'; let serverUp = false; test.beforeAll(async ({ request }) => { try { const r = await request.get(`${FD_BASE}/auth/login?provider=local`, { timeout: 4000 }); serverUp = r.status() < 500; } catch { serverUp = false; } }); test.beforeEach(async () => { test.skip(!serverUp, `FlowDeck server not reachable at ${FD_BASE}`); }); async function registerAndEnter(page) { await page.goto(`${FD_BASE}/auth/login?provider=local`); await page.click('button:has-text("Register")'); await page.fill('#name', 'PWA E2E'); await page.fill('#email', 'pwa' + Date.now() + '@flowdeck.local'); await page.fill('#password', FD_PASS); await page.click('.btn-primary'); await page.waitForURL('**/workspaces', { timeout: 20000 }); await page.waitForTimeout(800); await page.keyboard.press('Escape').catch(() => {}); const ws = await page.evaluate(async () => { const r = await fetch('/api/workspaces', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'PWA WS ' + Date.now() }), }); const d = await r.json(); await fetch('/api/workspaces/' + d.id + '/select', { method: 'POST' }); return d.id; }); await page.waitForTimeout(500); return ws; } async function waitForServiceWorker(page) { await page.evaluate(async () => { if ('serviceWorker' in navigator) { await navigator.serviceWorker.ready; if (!navigator.serviceWorker.controller) { await new Promise((resolve) => { navigator.serviceWorker.addEventListener('controllerchange', resolve, { once: true }); setTimeout(resolve, 4000); }); } } }); } test.describe('PWA — manifest & service worker', () => { test('manifest is linked and served with correct metadata', async ({ page }) => { await page.goto(`${FD_BASE}/auth/login?provider=local`); const href = await page.getAttribute('link[rel="manifest"]', 'href'); expect(href).toContain('manifest.json'); const manifest = await page.evaluate(async () => { const r = await fetch('/manifest.json'); return r.json(); }); expect(manifest.name).toBe('FlowDeck'); expect(manifest.display).toBe('standalone'); expect(Array.isArray(manifest.icons)).toBe(true); expect(manifest.icons.length).toBeGreaterThan(0); }); test('service worker registers and controls the page', async ({ page }) => { await registerAndEnter(page); await page.goto(`${FD_BASE}/local-workspace`); await waitForServiceWorker(page); const info = await page.evaluate(async () => { if (!('serviceWorker' in navigator)) return { supported: false }; const reg = await navigator.serviceWorker.ready; return { supported: true, active: !!reg.active, controlled: !!navigator.serviceWorker.controller, hasSync: 'sync' in reg, }; }); expect(info.supported).toBe(true); expect(info.active).toBe(true); expect(info.controlled).toBe(true); }); }); test.describe('PWA — offline queue & sync', () => { test('offline mutation is queued in IndexedDB and synced on reconnect', async ({ page, context }) => { test.setTimeout(120000); const wsId = await registerAndEnter(page); const pageId = await page.evaluate(async () => { const r = await fetch('/api/local-workspace/items', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Offline Doc', type: 'page' }), }); const d = await r.json(); return d.id; }); expect(pageId).toBeTruthy(); await page.goto(`${FD_BASE}/local-workspace`); await waitForServiceWorker(page); await page.waitForFunction(() => !!window.FlowOffline, null, { timeout: 10000 }); // ── go offline and queue a mutation ── await context.setOffline(true); const pendingAfterEnqueue = await page.evaluate(async (pid) => { await window.FlowOffline.enqueue('page_update', { page_id: pid, title: 'Offline Title', content: '[]', content_format: 'blocks', }); return window.FlowOffline.pendingCount(); }, pageId); expect(pendingAfterEnqueue).toBeGreaterThan(0); const stored = await page.evaluate(async () => { const q = await window.FlowOffline.getQueue(); return q.map((i) => i.type); }); expect(stored).toContain('page_update'); // ── reconnect: the queue must drain ── await context.setOffline(false); await page.evaluate(() => window.FlowOffline.flush()); await page.waitForFunction( () => window.FlowOffline.pendingCount().then((n) => n === 0), null, { timeout: 30000 } ); const pendingAfterSync = await page.evaluate(() => window.FlowOffline.pendingCount()); expect(pendingAfterSync).toBe(0); // ── server has the offline edit ── const synced = await page.evaluate(async (wid) => { const r = await fetch('/api/v2/sync/delta?since=0&workspace_id=' + wid); const d = await r.json(); return (d.changes || []).some((c) => c.data && c.data.title === 'Offline Title'); }, wsId); expect(synced).toBe(true); }); test('offline navigation is served from the app shell cache', async ({ page, context }) => { test.setTimeout(120000); await registerAndEnter(page); await page.goto(`${FD_BASE}/local-workspace`); await waitForServiceWorker(page); await page.waitForTimeout(1000); await context.setOffline(true); await page.reload({ waitUntil: 'domcontentloaded' }).catch(() => {}); const title = await page.title(); expect(title).toContain('FlowDeck'); await context.setOffline(false); }); });