diff --git a/frontend/e2e/error-boundary.spec.ts b/frontend/e2e/error-boundary.spec.ts new file mode 100644 index 0000000..e38c89e --- /dev/null +++ b/frontend/e2e/error-boundary.spec.ts @@ -0,0 +1,42 @@ +import { test, expect, type Page } from './fixtures'; + +// The root +error.svelte catches load() errors that a page rethrows rather than +// capturing in-band (e.g. the profile overview). Without it these fell through +// to SvelteKit's bare default error page — or blanked entirely on a raw +// TypeError. Drive the profile loader into a failure and assert the boundary. + +async function authed(page: Page) { + await page.route('**/api/v1/auth/config', (r) => + r.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ self_register_enabled: true, private_mode: false }) + }) + ); + await page.route('**/api/v1/auth/me', (r) => + r.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + user: { id: 'u1', username: 'reader', created_at: '2026-01-01T00:00:00Z', is_admin: false } + }) + }) + ); + await page.route('**/api/v1/auth/me/preferences', (r) => + r.fulfill({ status: 200, contentType: 'application/json', body: '{}' }) + ); +} + +test('profile: a network failure renders the error boundary, not a blank page', async ({ + page +}) => { + await authed(page); + // Profile's load rethrows non-401 errors; a raw abort is the TypeError case + // the client now normalises to an ApiError so it reaches the boundary. + await page.route('**/api/v1/me/bookmarks*', (r) => r.abort()); + await page.route('**/api/v1/me/collections*', (r) => r.abort()); + + await page.goto('/profile'); + await expect(page.getByTestId('error-boundary')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Try again' })).toBeVisible(); +}); diff --git a/frontend/package.json b/frontend/package.json index 1a63e12..ba45026 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.125.1", + "version": "0.125.2", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/api/client.test.ts b/frontend/src/lib/api/client.test.ts index ba00014..05746fd 100644 --- a/frontend/src/lib/api/client.test.ts +++ b/frontend/src/lib/api/client.test.ts @@ -80,6 +80,17 @@ describe('request error envelope parsing', () => { expect(err.code).toBe('http_error'); }); + it('wraps a network-level fetch rejection in ApiError (status 0)', async () => { + // Connection refused / offline / DNS: fetch rejects with a TypeError. + // It must surface as an ApiError, not a bare TypeError that crashes a + // load function into SvelteKit's default error page. + fetchSpy.mockRejectedValueOnce(new TypeError('Failed to fetch')); + const err = (await getManga('x').catch((e) => e)) as ApiError; + expect(err).toBeInstanceOf(ApiError); + expect(err.status).toBe(0); + expect(err.code).toBe('network_error'); + }); + it('treats empty 200/201 bodies as undefined (no JSON.parse crash)', async () => { // Regression: addMangaToCollection is typed `void` and the // backend returns 201 (created) / 200 (already there) with diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index f82075b..860a437 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -103,7 +103,21 @@ export async function request( // working. For same-origin requests this is a no-op compared to the // default 'same-origin', so the same-origin happy path is // unchanged. - const res = await fetch(`${BASE}${path}`, { credentials: 'include', ...init }); + let res: Response; + try { + res = await fetch(`${BASE}${path}`, { credentials: 'include', ...init }); + } catch { + // A network-level failure (connection refused, DNS, offline, blocked by + // CORS) rejects `fetch` with a bare TypeError instead of returning a + // response. Normalise it to an ApiError (status 0 = "no response reached + // us") so callers and SvelteKit load functions handle it uniformly + // instead of crashing on an unexpected TypeError. + throw new ApiError( + 0, + 'network_error', + 'Could not reach the server. Check your connection and try again.' + ); + } if (!res.ok) { let code = 'http_error'; let message = `${res.status} ${res.statusText}`; diff --git a/frontend/src/routes/+error.svelte b/frontend/src/routes/+error.svelte new file mode 100644 index 0000000..2fb3eae --- /dev/null +++ b/frontend/src/routes/+error.svelte @@ -0,0 +1,73 @@ + + +
+

{status || 'Error'}

+

{isNetwork ? 'We hit a snag' : 'This page is unavailable'}

+

{message}

+
+ + +
+
+ +