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>
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { getSupabase } from '$lib/supabase';
|
import { getSupabase } from '$lib/supabase';
|
||||||
import { currentCollective, userCollectives } from '$lib/stores/collective';
|
import { currentCollective, userCollectives } from '$lib/stores/collective';
|
||||||
|
import Spinner from './Spinner.svelte';
|
||||||
import * as m from '$lib/paraglide/messages';
|
import * as m from '$lib/paraglide/messages';
|
||||||
|
|
||||||
let { onClose }: { onClose: () => void } = $props();
|
let { onClose }: { onClose: () => void } = $props();
|
||||||
@@ -102,11 +103,16 @@
|
|||||||
type="submit"
|
type="submit"
|
||||||
data-testid="create-collective-modal-submit"
|
data-testid="create-collective-modal-submit"
|
||||||
disabled={!name.trim() || creating}
|
disabled={!name.trim() || creating}
|
||||||
class="rounded-lg bg-slate-900 px-4 py-1.5 text-sm font-semibold text-white
|
class="inline-flex items-center gap-2 rounded-lg bg-slate-900 px-4 py-1.5 text-sm font-semibold text-white
|
||||||
transition-opacity hover:opacity-90 disabled:opacity-50
|
transition-opacity hover:opacity-90 disabled:opacity-50
|
||||||
dark:bg-slate-50 dark:text-slate-900"
|
dark:bg-slate-50 dark:text-slate-900"
|
||||||
>
|
>
|
||||||
{creating ? m.loading() : m.create_collective_modal_button()}
|
{#if creating}
|
||||||
|
<Spinner size="sm" />
|
||||||
|
<span>{m.loading()}</span>
|
||||||
|
{:else}
|
||||||
|
{m.create_collective_modal_button()}
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
import BottomTabBar from '$lib/components/layout/BottomTabBar.svelte';
|
import BottomTabBar from '$lib/components/layout/BottomTabBar.svelte';
|
||||||
import MobileDrawer from '$lib/components/layout/MobileDrawer.svelte';
|
import MobileDrawer from '$lib/components/layout/MobileDrawer.svelte';
|
||||||
import UndoToast from '$lib/components/UndoToast.svelte';
|
import UndoToast from '$lib/components/UndoToast.svelte';
|
||||||
|
import Spinner from '$lib/components/Spinner.svelte';
|
||||||
import * as m from '$lib/paraglide/messages';
|
import * as m from '$lib/paraglide/messages';
|
||||||
|
|
||||||
let { children }: { children: Snippet } = $props();
|
let { children }: { children: Snippet } = $props();
|
||||||
@@ -149,8 +150,9 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $authLoading}
|
{#if $authLoading}
|
||||||
<div class="flex h-screen items-center justify-center bg-background">
|
<div class="flex h-screen flex-col items-center justify-center gap-3 bg-background text-text-secondary">
|
||||||
<span class="text-sm text-text-secondary">{m.loading()}</span>
|
<Spinner size="lg" />
|
||||||
|
<span class="text-sm">{m.loading()}</span>
|
||||||
</div>
|
</div>
|
||||||
{:else if $isAuthenticated}
|
{:else if $isAuthenticated}
|
||||||
<!-- Mobile: column stack (top bar → main → bottom tab bar).
|
<!-- Mobile: column stack (top bar → main → bottom tab bar).
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { currentUser } from '$lib/stores/auth';
|
import { currentUser } from '$lib/stores/auth';
|
||||||
import { currentCollective, collectiveMembers, userCollectives } from '$lib/stores/collective';
|
import { currentCollective, collectiveMembers, userCollectives } from '$lib/stores/collective';
|
||||||
import Avatar from '$lib/components/Avatar.svelte';
|
import Avatar from '$lib/components/Avatar.svelte';
|
||||||
|
import Spinner from '$lib/components/Spinner.svelte';
|
||||||
import type { FeatureFlags, SectionKey, ItemFrequency } from '@colectivo/types';
|
import type { FeatureFlags, SectionKey, ItemFrequency } from '@colectivo/types';
|
||||||
import { SECTION_KEYS } from '@colectivo/types';
|
import { SECTION_KEYS } from '@colectivo/types';
|
||||||
import { setCollectiveFeature } from '$lib/stores/features';
|
import { setCollectiveFeature } from '$lib/stores/features';
|
||||||
@@ -551,7 +552,10 @@
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<p class="text-sm text-text-secondary">{m.loading()}</p>
|
<p class="flex items-center gap-2 text-sm text-text-secondary">
|
||||||
|
<Spinner size="sm" />
|
||||||
|
<span>{m.loading()}</span>
|
||||||
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<ul>
|
<ul>
|
||||||
{#each members as member}
|
{#each members as member}
|
||||||
@@ -766,10 +770,15 @@
|
|||||||
data-testid="generate-invite"
|
data-testid="generate-invite"
|
||||||
onclick={generateInviteLink}
|
onclick={generateInviteLink}
|
||||||
disabled={generating}
|
disabled={generating}
|
||||||
class="mb-3 w-full rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white
|
class="mb-3 inline-flex w-full items-center justify-center gap-2 rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white
|
||||||
hover:opacity-90 disabled:opacity-50 dark:bg-slate-50 dark:text-slate-900"
|
hover:opacity-90 disabled:opacity-50 dark:bg-slate-50 dark:text-slate-900"
|
||||||
>
|
>
|
||||||
{generating ? m.loading() : m.manage_generate_link()}
|
{#if generating}
|
||||||
|
<Spinner size="sm" />
|
||||||
|
<span>{m.loading()}</span>
|
||||||
|
{:else}
|
||||||
|
{m.manage_generate_link()}
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if generatedLink}
|
{#if generatedLink}
|
||||||
@@ -1018,10 +1027,15 @@
|
|||||||
data-testid="common-item-add-save"
|
data-testid="common-item-add-save"
|
||||||
disabled={!addName.trim() || addSaving}
|
disabled={!addName.trim() || addSaving}
|
||||||
onclick={() => void confirmAdd()}
|
onclick={() => void confirmAdd()}
|
||||||
class="rounded-md bg-slate-900 px-4 py-1.5 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-50
|
class="inline-flex items-center gap-2 rounded-md bg-slate-900 px-4 py-1.5 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-50
|
||||||
dark:bg-slate-50 dark:text-slate-900"
|
dark:bg-slate-50 dark:text-slate-900"
|
||||||
>
|
>
|
||||||
{addSaving ? m.loading() : m.common_items_add_save()}
|
{#if addSaving}
|
||||||
|
<Spinner size="sm" />
|
||||||
|
<span>{m.loading()}</span>
|
||||||
|
{:else}
|
||||||
|
{m.common_items_add_save()}
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { loadArchivedNotes, unarchiveNote, trashNote } from '$lib/stores/notes';
|
import { loadArchivedNotes, unarchiveNote, trashNote } from '$lib/stores/notes';
|
||||||
import type { Note } from '@colectivo/types';
|
import type { Note } from '@colectivo/types';
|
||||||
import { ArrowLeft, ArchiveRestore, Trash2 } from 'lucide-svelte';
|
import { ArrowLeft, ArchiveRestore, Trash2 } from 'lucide-svelte';
|
||||||
|
import Spinner from '$lib/components/Spinner.svelte';
|
||||||
import * as m from '$lib/paraglide/messages';
|
import * as m from '$lib/paraglide/messages';
|
||||||
|
|
||||||
let archived = $state<Note[]>([]);
|
let archived = $state<Note[]>([]);
|
||||||
@@ -44,7 +45,10 @@
|
|||||||
|
|
||||||
<div class="flex-1 overflow-y-auto px-6 py-6">
|
<div class="flex-1 overflow-y-auto px-6 py-6">
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<p class="text-sm text-text-secondary">{m.loading()}</p>
|
<p class="flex items-center gap-2 text-sm text-text-secondary">
|
||||||
|
<Spinner size="sm" />
|
||||||
|
<span>{m.loading()}</span>
|
||||||
|
</p>
|
||||||
{:else if archived.length === 0}
|
{:else if archived.length === 0}
|
||||||
<p class="text-sm text-text-secondary">{m.notes_archived_empty()}</p>
|
<p class="text-sm text-text-secondary">{m.notes_archived_empty()}</p>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { Search, FileText, CheckSquare, ShoppingCart } from 'lucide-svelte';
|
import { Search, FileText, CheckSquare, ShoppingCart } from 'lucide-svelte';
|
||||||
import * as m from '$lib/paraglide/messages';
|
import * as m from '$lib/paraglide/messages';
|
||||||
import ScreenMasthead from '$lib/components/ScreenMasthead.svelte';
|
import ScreenMasthead from '$lib/components/ScreenMasthead.svelte';
|
||||||
|
import Spinner from '$lib/components/Spinner.svelte';
|
||||||
|
|
||||||
let query = $state('');
|
let query = $state('');
|
||||||
let results = $state<SearchRow[]>([]);
|
let results = $state<SearchRow[]>([]);
|
||||||
@@ -119,7 +120,10 @@
|
|||||||
{#if query.trim().length < 2}
|
{#if query.trim().length < 2}
|
||||||
<p class="text-sm text-text-secondary">{m.search_hint()}</p>
|
<p class="text-sm text-text-secondary">{m.search_hint()}</p>
|
||||||
{:else if loading}
|
{:else if loading}
|
||||||
<p class="text-sm text-text-secondary">{m.loading()}</p>
|
<p class="flex items-center gap-2 text-sm text-text-secondary">
|
||||||
|
<Spinner size="sm" />
|
||||||
|
<span>{m.loading()}</span>
|
||||||
|
</p>
|
||||||
{:else if results.length === 0}
|
{:else if results.length === 0}
|
||||||
<p class="text-sm text-text-secondary">{m.search_empty()}</p>
|
<p class="text-sm text-text-secondary">{m.search_empty()}</p>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
149
apps/web/tests/e2e/spinner.test.ts
Normal file
149
apps/web/tests/e2e/spinner.test.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user