mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 16:16:59 +00:00
test: check the sign-in, admin and setup skeletons stand in for their spinners
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user