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
This commit is contained in:
+2
-1
@@ -4,7 +4,8 @@
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"test": "playwright test",
|
||||
"test:pwa": "playwright test pwa_offline.spec.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user