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>
109 lines
4.1 KiB
TypeScript
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();
|
|
});
|
|
});
|