feat(fase-2b): Realtime sync + offline queue + Modo Compra
Fase 2b closes the critical-path product differentiator. Two sessions on the
same list see each other's changes in real time, mutations survive network
drops and sync when reconnected, and a dedicated full-screen shopping view
optimises the in-store experience.
2b.1 Realtime
- `$lib/stores/realtimeSync.ts` wraps `postgres_changes` with an `applyItemEvent`
reducer (INSERT/UPDATE/DELETE → next items[]) and a `subscribeToList` helper
that awaits the SUBSCRIBED handshake before returning
- `/lists/[id]` subscribes in onMount, unsubscribes in onDestroy. Uses a
`pendingTempIds` set to deduplicate own-mutation echoes against optimistic
rows (matches by name+created_by+sort_order)
- Client-generated UUIDs for new inserts make the path idempotent: if the
server response is lost and we retry, the second POST hits PK-conflict
which the queue treats as success
- CHECKED section div now carries role="listitem" so Playwright locators
follow the item across sections
2b.2 Offline queue
- `$lib/sync/queue.ts` — SyncQueue class backed by IndexedDB (idb), FIFO flush,
MAX_ATTEMPTS=5 retry budget, PK-conflict-as-success short-circuit
- `$lib/sync/index.ts` — app-wide singleton, window.online listener flushes
automatically, hydrateSyncState() at page load restores pendingOpsCount
- `$lib/stores/syncStatus.ts` — derived store (offline | syncing | synced)
tracking navigator.onLine + queue depth
- SyncBanner component renders the offline/syncing indicator in the detail
and session views
- handleAdd enqueues on failure instead of reverting, so an offline mutation
keeps its optimistic row and syncs on reconnect
2b.3 Modo Compra
- `/lists/[id]/session/+page.svelte` — full-screen overlay (fixed inset-0
z-50) that covers the sidebar while keeping the normal auth routing.
56×56 toggles, flip animation shuffling items between TO BUY / CHECKED,
Finish Shopping confirmation modal → completeList → goto('/lists')
- Link from `/lists/[id]` header (data-testid=start-session) with the
new `list_start_session` message
Testing infra
- apps/web gets its own Vitest config (jsdom + fake-indexeddb/auto) and a
new `pnpm test:unit` script. `just test-all` now chains pgTAP → integration
→ unit → e2e so a single command is the gate.
- packages/test-utils/tests/sync-queue.test.ts (placeholder scaffold) removed —
replaced by `apps/web/src/lib/sync/queue.test.ts` co-located with the module
Totals: 103 tests green
16 pgTAP
59 Vitest integration (in packages/test-utils)
6 Vitest unit (in apps/web — the SyncQueue)
22 Playwright E2E (5 auth + 4 lists + 6 items + 2 realtime + 2 offline + 3 session)
2 skipped (realtime-presence — upstream bug, unchanged)
Documented in plan/fase-2b and CLAUDE.md.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,23 +1,90 @@
|
||||
/**
|
||||
* R-E2E series — Fase 2b.1 (dual-browser-context Realtime)
|
||||
* R-E2E series — Fase 2b.1 (dual-browser-context Realtime sync)
|
||||
*
|
||||
* Exercises the end-to-end Realtime sync path through the actual SvelteKit app.
|
||||
* Skipped until the app wires up the Realtime subscription in `/lists/[id]`.
|
||||
* Each test spins up two browser contexts (Ana + Borja), each with its own
|
||||
* Keycloak session, and verifies that a mutation in one context is visible
|
||||
* in the other without a page reload.
|
||||
*/
|
||||
import { test } from '@playwright/test';
|
||||
import { test, expect, type Browser } from '@playwright/test';
|
||||
import { USERS } from '../fixtures/users.js';
|
||||
import { loginAs } from '../fixtures/login.js';
|
||||
|
||||
test.describe.skip('Realtime sync between two user sessions (pending UI)', () => {
|
||||
test('R-E-01: Ana adds an item → Borja sees it without refreshing', async () => {
|
||||
// Given two browser contexts: Ana and Borja, both on /lists/[id]
|
||||
// When Ana adds "Milk" via her sticky-form
|
||||
// Then Borja's item list shows "Milk" within 2s (no page reload)
|
||||
const SEED_LIST_PATH = '/lists/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
|
||||
const ADD_ITEM_PLACEHOLDER = /add item|añadir producto/i;
|
||||
|
||||
async function loggedInListPage(browser: Browser, user: (typeof USERS)[keyof typeof USERS]) {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
await loginAs(page, user);
|
||||
await page.goto(SEED_LIST_PATH);
|
||||
await expect(page.getByPlaceholder(ADD_ITEM_PLACEHOLDER)).toBeVisible({ timeout: 15_000 });
|
||||
return { context, page };
|
||||
}
|
||||
|
||||
test.describe('Realtime sync — two sessions on the same list', () => {
|
||||
test('R-E-01: Ana adds an item → Borja sees it without refreshing', async ({ browser }) => {
|
||||
const ana = await loggedInListPage(browser, USERS.ana);
|
||||
const borja = await loggedInListPage(browser, USERS.borja);
|
||||
|
||||
try {
|
||||
const itemName = `R-E-01-${Date.now()}`;
|
||||
|
||||
// Ana adds the item via her sticky form
|
||||
const anaInput = ana.page.getByPlaceholder(ADD_ITEM_PLACEHOLDER);
|
||||
await anaInput.fill(itemName);
|
||||
await anaInput.press('Enter');
|
||||
await expect(ana.page.getByText(itemName)).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Borja should see it land via Realtime, no reload
|
||||
await expect(borja.page.getByText(itemName)).toBeVisible({ timeout: 5_000 });
|
||||
} finally {
|
||||
await ana.context.close();
|
||||
await borja.context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('R-E-02: Presence avatar — Borja sees Ana\'s avatar when she joins', async () => {
|
||||
// Given Borja is on /lists/[id]/session
|
||||
// When Ana opens the same list in her own browser
|
||||
// Then Borja's presence indicator shows Ana's avatar
|
||||
// When Ana leaves
|
||||
// Then Ana's avatar disappears from Borja's indicator within 3s
|
||||
test('R-E-02: Ana checks an item → Borja sees it checked', async ({ browser }) => {
|
||||
const ana = await loggedInListPage(browser, USERS.ana);
|
||||
const borja = await loggedInListPage(browser, USERS.borja);
|
||||
|
||||
borja.page.on('console', (m) => {
|
||||
if (m.type() === 'error') console.log(` borja [err] ${m.text()}`);
|
||||
});
|
||||
|
||||
try {
|
||||
const itemName = `R-E-02-${Date.now()}`;
|
||||
|
||||
const anaInput = ana.page.getByPlaceholder(ADD_ITEM_PLACEHOLDER);
|
||||
await anaInput.fill(itemName);
|
||||
await anaInput.press('Enter');
|
||||
await expect(ana.page.getByText(itemName)).toBeVisible({ timeout: 5_000 });
|
||||
await expect(borja.page.getByText(itemName)).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Ana checks the item. Wait 500ms after add to give the realtime
|
||||
// echo + dedupe time to settle; otherwise the row might still have
|
||||
// the temp UUID in the DOM and the button id changes under us.
|
||||
await ana.page.waitForTimeout(500);
|
||||
const anaRow = ana.page.locator('[role="listitem"]').filter({ hasText: itemName }).first();
|
||||
await anaRow.getByRole('button', { name: /toggle item/i }).click();
|
||||
|
||||
// Verify Ana's own UI flipped (she checked it locally)
|
||||
await expect(
|
||||
ana.page
|
||||
.locator('[role="listitem"]')
|
||||
.filter({ hasText: itemName })
|
||||
.getByRole('button', { name: /uncheck item/i })
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// And Borja should see it checked too via Realtime UPDATE
|
||||
await expect(
|
||||
borja.page
|
||||
.locator('[role="listitem"]')
|
||||
.filter({ hasText: itemName })
|
||||
.getByRole('button', { name: /uncheck item/i })
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
} finally {
|
||||
await ana.context.close();
|
||||
await borja.context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user