- 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
180 lines
6.1 KiB
JavaScript
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);
|
|
});
|
|
});
|