From e1cfc1d7263700fa9eefdcce5f81024722c17f1e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:33:39 +0100 Subject: [PATCH] test: check the sign-in, admin and setup skeletons stand in for their spinners --- .../__tests__/login-page-skeleton.test.tsx | 78 ++++++++++++++++++ .../__tests__/layout-client-loading.test.tsx | 37 +++++++++ .../setup/__tests__/setup-skeleton.test.tsx | 46 +++++++++++ .../admin/__tests__/admin-shell.test.tsx | 12 ++- .../__tests__/auth-skeletons.test.tsx | 82 +++++++++++++++++++ 5 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 app/(main)/[locale]/login/__tests__/login-page-skeleton.test.tsx create mode 100644 app/(main)/admin/__tests__/layout-client-loading.test.tsx create mode 100644 app/(main)/setup/__tests__/setup-skeleton.test.tsx create mode 100644 components/boot-skeleton/__tests__/auth-skeletons.test.tsx diff --git a/app/(main)/[locale]/login/__tests__/login-page-skeleton.test.tsx b/app/(main)/[locale]/login/__tests__/login-page-skeleton.test.tsx new file mode 100644 index 000000000..e0c3d6a4b --- /dev/null +++ b/app/(main)/[locale]/login/__tests__/login-page-skeleton.test.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { renderToString } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; + +// Until /api/config answers (and in the server HTML, where it never has), +// the login page draws its card's skeleton instead of a spinner, the same +// one signed-out visitors of the app pages see first. + +vi.mock('next/navigation', () => ({ + useParams: () => ({ locale: 'en' }), + useSearchParams: () => new URLSearchParams(), + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }), + usePathname: () => '/login', +})); +vi.mock('@/i18n/navigation', () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }), + usePathname: () => '/login', + Link: ({ children }: { children: React.ReactNode }) => children, +})); + +const config = vi.hoisted(() => ({ loading: true, oauthOnly: false })); + +vi.mock('@/hooks/use-config', () => ({ + useConfig: () => ({ + appName: 'Webmail', + isLoading: config.loading, + error: null, + jmapServerUrl: 'https://mail.example.org', + jmapServers: [], + loginShowHeading: true, + loginShowSubtitle: true, + loginShowVersion: true, + loginShowTotp: true, + oauthEnabled: config.oauthOnly, + oauthOnly: config.oauthOnly, + }), +})); + +// The provider's metadata never arrives in these tests. +vi.mock('@/lib/browser-navigation', async (importOriginal) => ({ + ...(await importOriginal()), + apiFetch: vi.fn(() => new Promise(() => {})), +})); + +const { default: LoginPage } = await import('../page'); + +describe('login page while the config loads', () => { + it('server-renders the card skeleton, not a spinner', () => { + config.loading = true; + const html = renderToString(); + expect(html).toContain('data-boot-skeleton="login"'); + expect(html).not.toContain('animate-spin'); + }); + + it('keeps the skeleton on the client until the config is there', () => { + config.loading = true; + const { container } = render(); + const region = container.querySelector('[data-boot-skeleton="login"]'); + expect(region).not.toBeNull(); + expect(region!.getAttribute('aria-busy')).toBe('true'); + expect(container.querySelector('[role="status"]')?.textContent).toBe('loading'); + expect(container.querySelector('.animate-spin')).toBeNull(); + }); + + it('draws the sign-in button\'s place while the provider is looked up, not a spinner', () => { + config.loading = false; + config.oauthOnly = true; + try { + const { container } = render(); + expect(container.querySelector('[data-boot-skeleton]')).toBeNull(); + expect(container.querySelector('.animate-spin')).toBeNull(); + expect(container.querySelector('.sk-shape.h-11')).not.toBeNull(); + } finally { + config.oauthOnly = false; + } + }); +}); diff --git a/app/(main)/admin/__tests__/layout-client-loading.test.tsx b/app/(main)/admin/__tests__/layout-client-loading.test.tsx new file mode 100644 index 000000000..a33668d5a --- /dev/null +++ b/app/(main)/admin/__tests__/layout-client-loading.test.tsx @@ -0,0 +1,37 @@ +import type { ReactNode } from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +// While the admin session is checked, the content column shows the page's +// header and section placeholders where the boot skeleton drew them, not a +// line of text at a different height. + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ replace: vi.fn(), push: vi.fn() }), + usePathname: () => '/admin/settings', +})); +vi.mock('@/lib/browser-navigation', () => ({ + // The session check never answers. + apiFetch: vi.fn(() => new Promise(() => {})), + getPathPrefix: () => '', + withBasePath: (href: string) => href, +})); +vi.mock('@/components/admin/admin-shell', () => ({ + AdminShell: ({ children }: { children: ReactNode }) =>
{children}
, +})); +vi.mock('../_unsaved-changes-guard', () => ({ UnsavedChangesGuard: () => null })); + +const { AdminLayoutClient } = await import('../_layout-client'); + +describe('admin layout while the session is checked', () => { + it('draws the page header and section placeholders in the content slot', () => { + render(

page

); + const main = screen.getByRole('main'); + expect(main.querySelector('[data-testid="admin-body-skeleton"]')).not.toBeNull(); + // The open page's own title and description, as AdminPageHeader shows them. + expect(main.textContent).toContain('How this Bulwark connects and what the login page offers'); + expect(screen.getByRole('status').textContent).toBe('Loading admin panel…'); + expect(screen.getByRole('status').className).toContain('sr-only'); + expect(screen.queryByText('page')).toBeNull(); + }); +}); diff --git a/app/(main)/setup/__tests__/setup-skeleton.test.tsx b/app/(main)/setup/__tests__/setup-skeleton.test.tsx new file mode 100644 index 000000000..5af673dd2 --- /dev/null +++ b/app/(main)/setup/__tests__/setup-skeleton.test.tsx @@ -0,0 +1,46 @@ +import { render } from '@testing-library/react'; +import { renderToString } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; +import { NextIntlClientProvider } from 'next-intl'; +import en from '@/locales/en/common.json'; + +// Until /api/setup/status answers (and in the server HTML), the wizard +// draws its own frame with the welcome step, not a card saying "Loading". + +vi.unmock('next-intl'); +vi.mock('next/navigation', () => ({ useRouter: () => ({ replace: vi.fn(), push: vi.fn() }) })); +vi.mock('@/lib/browser-navigation', () => ({ + apiFetch: vi.fn(() => new Promise(() => {})), + getPathPrefix: () => '', + withBasePath: (p: string) => p, +})); +vi.mock('@/lib/setup/url-token', () => ({ takeSetupTokenFromUrl: () => '' })); + +const { default: SetupWizardPage } = await import('../page'); + +const page = ( + + + +); + +describe('setup wizard while the status loads', () => { + it('server-renders the wizard frame', () => { + const container = document.createElement('div'); + container.innerHTML = renderToString(page); + const region = container.querySelector('[data-boot-skeleton="setup"]'); + expect(region).not.toBeNull(); + expect(region!.querySelector('[role="status"]')?.textContent).toBe(en.setup.loading); + // Header, all seven steps and the welcome card, in the page's words. + expect(region!.textContent).toContain(en.setup.title); + for (const step of Object.values(en.setup.steps)) expect(region!.textContent).toContain(step); + expect(region!.textContent).toContain(en.setup.welcome.token_label); + expect(container.querySelectorAll('a[href], button, input')).toHaveLength(0); + }); + + it('keeps it on the client until the status is there', () => { + const { container } = render(page); + expect(container.querySelector('[data-boot-skeleton="setup"]')).not.toBeNull(); + expect(container.querySelector('.max-w-md')).toBeNull(); + }); +}); diff --git a/components/admin/__tests__/admin-shell.test.tsx b/components/admin/__tests__/admin-shell.test.tsx index d1d081e7e..4594f76d7 100644 --- a/components/admin/__tests__/admin-shell.test.tsx +++ b/components/admin/__tests__/admin-shell.test.tsx @@ -37,10 +37,18 @@ describe('AdminShell', () => { Element.prototype.scrollTo = () => {}; }); - it('does not server-render the phone layout, which desktop would show until hydration', () => { + // The server can't know the width: it draws the panel's skeleton, whose + // phone and desktop layouts CSS picks, rather than either real layout. + it('server-renders the panel skeleton, not a real layout', () => { const html = renderToString(shell({ authenticated: false, isStalwartAutoLogin: false })); - expect(html).not.toContain('back_to_mail'); + const container = document.createElement('div'); + container.innerHTML = html; + const region = container.querySelector('[data-boot-skeleton="admin"]'); + expect(region).not.toBeNull(); + expect(region!.querySelector('[role="status"]')?.textContent).toBe('common.loading'); + expect(container.querySelectorAll('a[href], button, input')).toHaveLength(0); expect(html).not.toContain('admin_search'); + expect(html).not.toContain('

page

'); }); // The shell's own names come from the translations, and the tab says diff --git a/components/boot-skeleton/__tests__/auth-skeletons.test.tsx b/components/boot-skeleton/__tests__/auth-skeletons.test.tsx new file mode 100644 index 000000000..d7a75ad24 --- /dev/null +++ b/components/boot-skeleton/__tests__/auth-skeletons.test.tsx @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { renderToString } from 'react-dom/server'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { RouteSkeleton } from '../route-skeleton'; +import { BottomBarSkeleton, RailSkeleton } from '../shell'; +import { AdminSkeleton } from '../admin-skeleton'; +import type { BootSurface } from '@/lib/boot-skeleton/surfaces'; + +function dom(html: string) { + const container = document.createElement('div'); + container.innerHTML = html; + return container; +} + +const css = readFileSync(join(process.cwd(), 'app/boot-skeleton.css'), 'utf8'); + +// A signed-in page drawn for a visitor with nothing stored: the boot script +// sets data-boot-auth="out", and the CSS swaps the app's frame for the +// login card, the page the app is about to send them to. +describe('signed-in pages for a signed-out visitor', () => { + it('boot-out matches data-boot-auth="out"', () => { + expect(css).toMatch(/@custom-variant boot-out \(&:where\(\[data-boot-auth="out"\] \*\)\);/); + }); + + it.each(['mail', 'calendar', 'contacts', 'files', 'apps', 'settings'])('%s carries the login card, shown only with boot-out', (surface) => { + const container = dom(renderToString()); + const login = container.querySelector('[data-boot-skeleton="login"]'); + expect(login).not.toBeNull(); + // Hidden unless the page is marked signed out. + expect(login!.parentElement!.className.split(' ')).toEqual(expect.arrayContaining(['hidden', 'boot-out:block'])); + // The card itself: logo, fields, button. + expect(login!.querySelectorAll('.h-11').length).toBeGreaterThanOrEqual(3); + // And the app's own frame goes away then. + const app = container.querySelector(`[data-boot-skeleton="${surface}"]`); + expect(app!.className.split(' ')).toContain('boot-out:hidden'); + }); +}); + +describe('admin skeleton', () => { + it('draws the page list with its real names and the open page current', () => { + const container = dom(renderToString()); + const text = container.textContent ?? ''; + for (const name of ['Overview', 'Server', 'Branding', 'Organisation', 'Extensions', 'System', 'Audit log']) { + expect(text).toContain(name); + } + const current = [...container.querySelectorAll('.bg-accent')].map((el) => el.textContent); + expect(current).toEqual(['Branding']); + }); + + it('shows the phone list at /admin and the page below it', () => { + const index = dom(renderToString()); + const detail = dom(renderToString()); + const phone = (c: HTMLElement) => c.querySelector('.lg\\:hidden')!; + // Index: the list (with its chevrons) is shown, the page column hidden. + expect(phone(index).querySelector('.p-4')!.className).toContain('hidden'); + expect(phone(detail).querySelector('.p-4')!.className).not.toContain('hidden'); + // Unknown page (a connector link): the boot script's pane attribute decides. + const unknown = dom(renderToString()); + expect(phone(unknown).querySelector('.p-4')!.className).toContain('boot-open:block'); + }); +}); + +describe('rail with the admin panel current', () => { + it('always draws the admin entry, as current', () => { + const rail = dom(renderToString()); + const shield = rail.querySelector('.bg-primary\\/10')!; + expect(shield).not.toBeNull(); + expect(shield.className).not.toContain('hidden'); + // Without it, only the last session's hint shows the entry. + const plain = dom(renderToString()); + expect(plain.innerHTML).toContain('boot-rail-admin:flex'); + }); + + it('adds Admin to the bottom bar before Settings', () => { + const bar = dom(renderToString()); + const labels = [...bar.querySelectorAll('span.truncate')].map((el) => el.textContent); + expect(labels.slice(-2)).toEqual(['Admin', 'Settings']); + const plain = dom(renderToString()); + expect(plain.textContent).not.toContain('Admin'); + }); +});