Files
collective-lists/apps/web/tests/e2e/offline.test.ts
Oier Bravo Urtasun c8c7f9f8c9 feat(fase-14): offline chip + queued-op badge in global chrome (14.2.1-2)
Adds an OfflineChip surfaced in the MobileTopBar (compact, icon-only)
and at the bottom of the DesktopSidebar (full pill with label). Hidden
while online. When `navigator.onLine` is false:

  - The chip mounts with the existing amber palette
  - A title tooltip explains the implication ("changes will sync when
    you reconnect")
  - If `pendingOpsCount > 0`, a small numeric badge is appended so the
    user sees the actual backlog size at a glance

`pendingOpsCount` is the existing store (kept in sync by SyncQueue);
nothing new wires up — we only surface what was already tracked.

OF-01 e2e: drives `context.setOffline(true)`, asserts the chip
appears, queues an INSERT, asserts the numeric badge renders, and
verifies the chip disappears when back online. Added alongside the
existing O-01/O-02 (banner-based) so we keep coverage of both surfaces.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 07:02:00 +02:00

109 lines
4.1 KiB
TypeScript

/**
* 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('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();
});
});