Files
collective-lists/apps/web/tests/e2e/spinner.test.ts
Oier Bravo Urtasun fef186c1cb feat(fase-16): integrate Spinner at the 5 loading sites + E2E coverage
Fase 16.2 + 16.3 — replace the bare `m.loading()` text at every
pre-existing async-loading site with `<Spinner size=...> + <span>`.
Text is kept (visual fallback + tooltip / SR redundancy). No new
loading semantics — only existing flags get a visual indicator.

Sites:
- (app)/+layout.svelte: auth splash gets `size="lg"` centred under the
  loading label.
- (app)/search/+page.svelte: inline `size="sm"` next to the search-in-flight
  copy.
- (app)/notes/archive/+page.svelte: inline `size="sm"` next to the
  archive-load copy.
- lib/components/CreateCollectiveModal.svelte: inline `size="sm"` inside
  the disabled Save button while the create RPC is in flight.
- (app)/collective/manage/+page.svelte: three sites — members-list load,
  invitation-generate button, and add-common-item Save button.

E2E (tests/e2e/spinner.test.ts):
- SP-E-01 throttles search_in_collective RPC by 500ms and asserts the
  spinner is visible in the loading paragraph.
- SP-E-02 throttles set_item_frequency_weight RPC and asserts the spinner
  is scoped inside the Save button.
- SP-E-03 seeds an expired `sb-192-auth-token` so GoTrue is forced to
  call /auth/v1/token, then hangs that endpoint indefinitely and
  asserts the splash spinner is visible. Storage key derivation matches
  Supabase's `sb-${hostname.split('.')[0]}-auth-token` default; we
  populate both the LAN-IP variant and a loopback fallback for
  resilience.

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

150 lines
5.8 KiB
TypeScript

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