/** * O-series — Fase 2b.2 (offline-first) * * Uses Playwright's `context.setOffline(true)` to simulate a dropped * connection, then verifies that: * (a) mutations still work locally (optimistic UI + enqueue) * (b) a banner tells the user they're offline * (c) going back online flushes the queue and the items persist server-side */ import { test, expect } from '@playwright/test'; import { USERS } from '../fixtures/users.js'; import { loginAs } from '../fixtures/login.js'; const SEED_LIST_PATH = '/lists/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; const ADD_ITEM_PLACEHOLDER = /add item|añadir producto/i; test.describe('Sync conflicts review', () => { test('OF-02: a planted sync_conflicts row surfaces the banner + lists on /settings/sync-conflicts, and Discard removes the row', async ({ page }) => { await loginAs(page, USERS.borja); await page.goto('/lists'); // Wait for the layout's conflict probe to run + complete (it's // deferred via setTimeout(0)). await page.waitForFunction( () => Boolean((window as unknown as { __sb?: unknown }).__sb), null, { timeout: 10_000 } ); // sync_conflicts is append-only at the RLS layer (migration 016 has // no DELETE policy). Instead of deleting, we pre-dismiss every // existing row in localStorage so the route + banner reflect only // the row we plant in this test. await page.evaluate(async () => { const sb = (window as unknown as { __sb: { auth: { getUser: () => Promise<{ data: { user: { id: string } | null } }> }; from: (t: string) => { select: (c: string) => { eq: (k: string, v: string) => Promise<{ data: { id: string }[] | null }> }; insert: (row: unknown) => Promise; }; }; }).__sb; const u = (await sb.auth.getUser()).data.user; if (!u) throw new Error('no user'); const existing = (await sb.from('sync_conflicts').select('id').eq('user_id', u.id)).data ?? []; localStorage.setItem('syncConflictsDismissed', JSON.stringify(existing.map((r) => r.id))); await sb.from('sync_conflicts').insert({ user_id: u.id, collective_id: null, entity_type: 'shopping_item', entity_id: '00000000-0000-0000-0000-000000000000', local_version: { name: 'OF-02-local' }, remote_version: { name: 'OF-02-remote' }, resolution: 'remote_won' }); }); // Reload to re-trigger the probe with the planted row in place. await page.reload(); await expect(page.getByTestId('sync-conflicts-banner')).toBeVisible({ timeout: 15_000 }); await page.getByTestId('sync-conflicts-banner').click(); await expect(page).toHaveURL(/\/settings\/sync-conflicts$/); const row = page.getByTestId('sync-conflicts-route-row').first(); await expect(row).toBeVisible({ timeout: 5_000 }); await row.getByTestId('sync-conflicts-discard-local').click(); await expect(page.getByTestId('sync-conflicts-route-empty')).toBeVisible({ timeout: 3_000 }); // Tests can't DELETE from sync_conflicts (RLS append-only) — the // row stays in the table. Dismissal lives in localStorage so the // next run's pre-dismissal pass keeps it out of view. }); }); test.describe('Offline queue + reconnect flush', () => { test('O-01: going offline shows the banner and optimistic adds stay visible', async ({ page, context }) => { await loginAs(page, USERS.borja); await page.goto(SEED_LIST_PATH); await expect(page.getByPlaceholder(ADD_ITEM_PLACEHOLDER)).toBeVisible({ timeout: 15_000 }); // Go offline. The SyncBanner subscribes to `navigator.onLine` via the // isOnline store and should render within a tick. await context.setOffline(true); await expect(page.getByTestId('sync-banner-offline')).toBeVisible({ timeout: 3_000 }); const itemName = `O-01-${Date.now()}`; const input = page.getByPlaceholder(ADD_ITEM_PLACEHOLDER); await input.fill(itemName); await input.press('Enter'); // Item renders locally even though the server call failed. await expect(page.getByText(itemName)).toBeVisible({ timeout: 3_000 }); // Cleanup: bring the page back online so the next test starts fresh. await context.setOffline(false); }); test('OF-01: going offline shows the persistent chip + a queue badge', async ({ page, context }) => { await loginAs(page, USERS.borja); await page.goto(SEED_LIST_PATH); await expect(page.getByPlaceholder(ADD_ITEM_PLACEHOLDER)).toBeVisible({ timeout: 15_000 }); // Online → chip hidden. await expect(page.getByTestId('pwa-offline-chip')).toHaveCount(0); await context.setOffline(true); // At least one chip (mobile + desktop both mount; only the visible one // matters but locator-count-based assertions need both). await expect(page.getByTestId('pwa-offline-chip').first()).toBeVisible({ timeout: 3_000 }); const itemName = `OF-01-${Date.now()}`; const input = page.getByPlaceholder(ADD_ITEM_PLACEHOLDER); await input.fill(itemName); await input.press('Enter'); // pending_ops should now contain at least the INSERT for the new item. await expect(page.getByTestId('pwa-offline-chip-badge').first()).toHaveText(/\d+/, { timeout: 3_000 }); await expect(page.getByText(itemName)).toBeVisible({ timeout: 3_000 }); await context.setOffline(false); await expect(page.getByTestId('pwa-offline-chip')).toHaveCount(0, { timeout: 5_000 }); }); test('O-02: going back online flushes the queue to the server', async ({ browser, context }) => { const page = await context.newPage(); await loginAs(page, USERS.borja); await page.goto(SEED_LIST_PATH); await expect(page.getByPlaceholder(ADD_ITEM_PLACEHOLDER)).toBeVisible({ timeout: 15_000 }); await context.setOffline(true); await expect(page.getByTestId('sync-banner-offline')).toBeVisible({ timeout: 3_000 }); const itemName = `O-02-${Date.now()}`; const input = page.getByPlaceholder(ADD_ITEM_PLACEHOLDER); await input.fill(itemName); await input.press('Enter'); await expect(page.getByText(itemName)).toBeVisible({ timeout: 3_000 }); // Back online — the queue's 'online' listener flushes pending ops. await context.setOffline(false); await expect(page.getByTestId('sync-banner-offline')).not.toBeVisible({ timeout: 5_000 }); // Verify server-side persistence with a FRESH independent context: // reload would use the same browser state; we want to prove the row // actually made it to the DB by loading the list from a new session. const verifyContext = await browser.newContext(); const verifyPage = await verifyContext.newPage(); await loginAs(verifyPage, USERS.ana); await verifyPage.goto(SEED_LIST_PATH); await expect(verifyPage.getByText(itemName)).toBeVisible({ timeout: 15_000 }); await verifyContext.close(); }); });