/** * SP-series — Fase 16 (loading spinner visual feedback). * * The Spinner component itself is unit-tested in * `src/lib/components/Spinner.test.ts` (SP-U-01..05). These E2E specs assert * that the spinner actually shows up at the five integration sites listed in * `plan/fase-16-spinner.md` when the backing async operation is in flight. * * Strategy: every spec uses `page.route(...)` to add ~500ms of artificial * latency to the request that drives the spinner so the assertion window is * deterministic — without the throttle the loopback round-trip resolves in * <30ms and the spinner can flicker past Playwright's auto-wait cadence. */ import { test, expect } from '@playwright/test'; import { USERS } from '../fixtures/users.js'; import { loginAs } from '../fixtures/login.js'; const THROTTLE_MS = 500; test.describe('Loading spinner', () => { test('SP-E-01: /search shows the spinner while the search RPC is in flight', async ({ page }) => { await loginAs(page, USERS.borja); // Throttle the search RPC so the loading window stays open long enough // for the spinner assertion below. await page.route('**/rest/v1/rpc/search_in_collective**', async (route) => { await new Promise((r) => setTimeout(r, THROTTLE_MS)); await route.continue(); }); await page.goto('/search'); const input = page.getByPlaceholder(/search|buscar/i); await expect(input).toBeVisible({ timeout: 15_000 }); await input.fill('Milk'); // Debounce is 300ms + 500ms throttle ≈ ~800ms window where the // spinner is mounted in the `{#if loading}` branch of /search. const spinner = page.getByTestId('spinner').first(); await expect(spinner).toBeVisible({ timeout: 5_000 }); // And it disappears once the RPC resolves. await expect(spinner).toHaveCount(0, { timeout: 10_000 }); }); test('SP-E-02: Save button in "Add common item" modal shows the spinner while saving', async ({ page }) => { await loginAs(page, USERS.ana); // Throttle the set_item_frequency_weight RPC so addSaving stays true // long enough for the spinner to be visible. await page.route( '**/rest/v1/rpc/set_item_frequency_weight**', async (route) => { await new Promise((r) => setTimeout(r, THROTTLE_MS)); await route.continue(); } ); await page.goto('/collective/manage'); await page.waitForLoadState('networkidle'); await page.getByTestId('common-item-add-button').click(); const nameInput = page.getByTestId('common-item-add-name'); await expect(nameInput).toBeVisible({ timeout: 5_000 }); await nameInput.fill(`sp-e-02-${Date.now()}`); const saveButton = page.getByTestId('common-item-add-save'); await saveButton.click(); // Spinner is scoped inside the Save button so we look for any spinner // that's also a descendant of the button — this avoids matching some // unrelated spinner elsewhere on the page. await expect(saveButton.getByTestId('spinner')).toBeVisible({ timeout: 5_000 }); }); test('SP-E-03: auth splash shows the spinner while Supabase resolves the session', async ({ browser }) => { // The splash in `(app)/+layout.svelte` is gated on `$authLoading`, // which only flips false once Supabase's `onAuthStateChange` fires // `INITIAL_SESSION`. With no stored session the emit is synchronous // and the splash flashes past Playwright's auto-wait. // // Trick: seed localStorage with an *expired* sb-* auth token so // GoTrue's `_recoverAndRefresh` is forced to hit // `/auth/v1/token?grant_type=refresh_token`. Throttle that endpoint // by 500ms and the splash stays mounted for the assertion window. const context = await browser.newContext(); const page = await context.newPage(); await context.addInitScript(() => { // Synthetic expired Supabase session. We can't sign it correctly, // but GoTrue trusts the stored payload and skips straight to // refresh as soon as it sees expires_at in the past. const payload = { access_token: 'sp-e-03-fake-access', token_type: 'bearer', expires_in: 3600, expires_at: Math.floor(Date.now() / 1000) - 60, refresh_token: 'sp-e-03-fake-refresh', user: { id: '00000000-0000-0000-0000-000000000000', aud: 'authenticated', role: 'authenticated', email: 'spinner-splash@dev.local', app_metadata: { provider: 'keycloak' }, user_metadata: {}, created_at: new Date().toISOString() } }; // Supabase derives the storage key from the URL hostname (first // label only): `sb-${hostname.split('.')[0]}-auth-token`. Our dev // PUBLIC_SUPABASE_URL is http://192.168.1.167:8001 → key // `sb-192-auth-token`. We populate every plausible key so the // test is resilient to a localhost override. const KEYS = ['sb-192-auth-token', 'sb-localhost-auth-token']; for (const k of KEYS) localStorage.setItem(k, JSON.stringify(payload)); }); // Stall the GoTrue token-refresh endpoint indefinitely. With the // seeded expired session above, `_recoverAndRefresh` is forced to // call POST /auth/v1/token?grant_type=refresh_token before emitting // INITIAL_SESSION. While the call is in flight `authLoading` stays // true and the splash is mounted, which is exactly the window the // assertion needs. We never `route.continue()` — the test asserts // the spinner is visible and then tears the context down. let releaseToken: (() => void) | null = null; const tokenStall = new Promise((resolve) => { releaseToken = resolve; }); await page.route('**/auth/v1/token**', async (route) => { await tokenStall; await route.fulfill({ status: 503, body: '{}' }); }); try { await page.goto('/lists', { waitUntil: 'commit' }); const spinner = page.getByTestId('spinner').first(); await expect(spinner).toBeVisible({ timeout: 10_000 }); } finally { // Release the stalled refresh so the context can shut down cleanly. if (releaseToken) releaseToken(); await context.close(); } }); });