Some checks failed
CD / Build and push images (push) Successful in 2m59s
CI / Lint, typecheck, test (push) Successful in 2m3s
CI / Auth e2e pack (push) Failing after 2m18s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m32s
CD / Promote to Int (push) Successful in 12s
Editing continues without a connection and merges conflict-free on reconnect (ADR 0003, realtime-collaboration.md §Offline). - y-indexeddb mirrors every opened page's Y.Doc to IndexedDB, sharing the document with the collab provider. The local copy is discarded when the page is left after a successful server sync (bounding IndexedDB growth) and kept otherwise so offline edits survive to the next visit. - vite-plugin-pwa service worker precaches the app shell (build assets only) with a navigation fallback; `/api` and `/collab` are denylisted and there is no runtime caching, so API responses are never cached or poisoned. - Offline page resolution WITHOUT caching API responses: the app itself persists the small metadata it needs to reopen a visited page (page/pond ids + slugs, bounded LRU in localStorage) and the last signed-in user, so after an offline tab reload the app stays signed in, resolves the page, and restores its content from IndexedDB. Both are revalidated when the network returns (a 401 clears the cached user). - Local-only UI: a banner when there are edits held only on this device (provider `onUnsyncedChanges`), de + en. Tests: `page-cache` unit test (remember/recall + bounded eviction); a new `offline` e2e pack (validated locally against the full stack and wired into CI): edit, reload while offline (shell from the SW, content from IndexedDB), assert an API call fails offline (no SW API caching), then reconnect and a second client converges. The e2e static server serves `.webmanifest`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
83 lines
3.2 KiB
TypeScript
83 lines
3.2 KiB
TypeScript
import { expect, test } from '@playwright/test';
|
|
import type { BrowserContext } from '@playwright/test';
|
|
|
|
import { contextForUser } from './helpers';
|
|
|
|
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
|
|
|
|
async function personalPond(context: BrowserContext): Promise<{ id: string; slug: string }> {
|
|
const ponds = await context.request.get('/api/v1/ponds');
|
|
const pond = (await ponds.json()).find((p: { type: string }) => p.type === 'personal');
|
|
return { id: pond.id, slug: pond.slug };
|
|
}
|
|
|
|
test('offline: edit, reload while offline, then reconnect converges (#38)', async ({ browser }) => {
|
|
const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
|
|
const pond = await personalPond(owner);
|
|
const created = await owner.request.post(`/api/v1/ponds/${pond.id}/pages`, {
|
|
data: { title: `Offline ${Date.now()}` },
|
|
});
|
|
const { slug } = await created.json();
|
|
|
|
const page = await owner.newPage();
|
|
await page.goto(`/p/${pond.slug}/${slug}`);
|
|
await page.getByRole('button', { name: /edit|bearbeiten/i }).click();
|
|
await expect(page.locator('.editor-connection')).toHaveAttribute('data-status', 'connected', {
|
|
timeout: 15000,
|
|
});
|
|
// The service worker must control the page before an offline reload can be
|
|
// served from the cached app shell.
|
|
await page.waitForFunction(() => navigator.serviceWorker?.controller != null, null, {
|
|
timeout: 20000,
|
|
});
|
|
|
|
const editor = page.locator('.ProseMirror');
|
|
await editor.click();
|
|
await page.keyboard.type('online base ');
|
|
await expect(editor).toContainText('online base');
|
|
|
|
// Go offline and keep editing — the connection indicator flips and edits
|
|
// continue locally.
|
|
await owner.setOffline(true);
|
|
await expect(page.locator('.editor-connection')).toHaveAttribute('data-status', 'offline', {
|
|
timeout: 10000,
|
|
});
|
|
await editor.type('offline extra ');
|
|
await expect(editor).toContainText('offline extra');
|
|
|
|
// No service-worker caching of API responses: an API call offline fails
|
|
// (network error) rather than being served a stale cached 200.
|
|
const apiResult = await page.evaluate(async () => {
|
|
try {
|
|
await fetch('/api/v1/ponds', { cache: 'no-store' });
|
|
return 'ok';
|
|
} catch {
|
|
return 'network-error';
|
|
}
|
|
});
|
|
expect(apiResult).toBe('network-error');
|
|
|
|
// Give y-indexeddb a moment to flush the offline edit before the reload.
|
|
await page.waitForTimeout(1000);
|
|
|
|
// Reload the tab while offline: the app shell loads from the service worker,
|
|
// the page resolves from the offline metadata cache, and its content comes
|
|
// back from IndexedDB.
|
|
await page.reload();
|
|
const reloaded = page.locator('.ProseMirror');
|
|
await expect(reloaded).toContainText('offline extra', { timeout: 20000 });
|
|
await expect(reloaded).toContainText('online base');
|
|
|
|
// Back online: a second participant converges on the merged content.
|
|
await owner.setOffline(false);
|
|
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
|
|
const adminPage = await admin.newPage();
|
|
await adminPage.goto(`/p/${pond.slug}/${slug}`);
|
|
await expect(adminPage.locator('.ProseMirror')).toContainText('offline extra', {
|
|
timeout: 25000,
|
|
});
|
|
|
|
await owner.close();
|
|
await admin.close();
|
|
});
|