test: check the sign-in, admin and setup skeletons stand in for their spinners

This commit is contained in:
Linus Rath
2026-11-01 19:33:39 +01:00
parent f5bcc8d47d
commit e1cfc1d726
5 changed files with 253 additions and 2 deletions
@@ -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<typeof import('@/lib/browser-navigation')>()),
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(<LoginPage />);
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(<LoginPage />);
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(<LoginPage />);
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;
}
});
});
@@ -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 }) => <main>{children}</main>,
}));
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(<AdminLayoutClient><p>page</p></AdminLayoutClient>);
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();
});
});
@@ -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 = (
<NextIntlClientProvider locale="en" messages={en}>
<SetupWizardPage />
</NextIntlClientProvider>
);
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();
});
});
@@ -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('<p>page</p>');
});
// The shell's own names come from the translations, and the tab says
@@ -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<BootSurface>(['mail', 'calendar', 'contacts', 'files', 'apps', 'settings'])('%s carries the login card, shown only with boot-out', (surface) => {
const container = dom(renderToString(<RouteSkeleton surface={surface} label="Loading" />));
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(<AdminSkeleton page="branding" label="Loading" />));
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(<AdminSkeleton page={null} />));
const detail = dom(renderToString(<AdminSkeleton page="logs" />));
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(<AdminSkeleton />));
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(<RailSkeleton active="admin" />));
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(<RailSkeleton active="mail" />));
expect(plain.innerHTML).toContain('boot-rail-admin:flex');
});
it('adds Admin to the bottom bar before Settings', () => {
const bar = dom(renderToString(<BottomBarSkeleton active="admin" labels={{ mail: 'Mail', admin: 'Admin', settings: 'Settings' }} />));
const labels = [...bar.querySelectorAll('span.truncate')].map((el) => el.textContent);
expect(labels.slice(-2)).toEqual(['Admin', 'Settings']);
const plain = dom(renderToString(<BottomBarSkeleton active="mail" labels={{ admin: 'Admin' }} />));
expect(plain.textContent).not.toContain('Admin');
});
});