Files
flowdeck/e2e/pwa_offline.spec.js
T
bruno b5207216f1 feat: v6.0.0 PWA offline support
- manifest + icones, service worker (precache, network-first, Background Sync)

- module client FlowOffline (IndexedDB, queue, delta, flush) + hook editeur

- endpoints /api/v2/sync/{delta,batch,status} + moteur de sync (conflits LWW/orpheline/copie offline)

- migrations offline_sync_queue + sync_version (triggers)

- UI offline (banner, badge sync, toasts, icone dirty) + doc /help

- tests pytest (sync, migrations, SW, offline) + E2E Playwright; bump 6.0.0
2026-09-18 13:05:40 -04:00

180 lines
6.1 KiB
JavaScript

/**
* 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);
});
});