release: v0.19.0

Release v0.19.0. See the `## [0.19.0] — 2026-09-29` section of
[CHANGELOG.md](https://github.com/ralksta/immich-folio/blob/dev/CHANGELOG.md)
for the full list.

Highlights: client proofing links (#683), custom content pages (#738),
asset picker search (#740), concurrent-edit detection (#742), env-locked
settings (#739), an accessibility baseline (#741), three security fixes
with advisories (#737, #755, #759), and a pre-release quality pass
(#780–#797).

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ralf <rohlenbo79@gmail.com>
Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
This commit is contained in:
Ralksta
2026-09-29 22:50:30 +02:00
committed by GitHub
parent 5f251923f0
commit ad8b09700b
297 changed files with 19398 additions and 1800 deletions

View File

@@ -0,0 +1,55 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
import ContactForm from '../ContactForm';
import { validateContact } from '@/lib/contact';
/**
* The fill-time check compares `startedAt` against the server's clock. When
* the form stamped it with the visitor's clock, a device running a few
* minutes fast made every message look "sent before the form existed": the
* route answered `ok` (as it does for bots) and dropped the message, and the
* visitor saw the thank-you page.
*/
const SERVER_RENDERED_AT = 1_800_000_000_000;
const MINUTE = 60_000;
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.unstubAllGlobals();
});
async function submitWithClientClock(clientNow: number) {
vi.useFakeTimers({ now: clientNow, toFake: ['Date'] });
const fetchMock = vi.fn(async (_url: string, _init: RequestInit) => Response.json({ ok: true }));
vi.stubGlobal('fetch', fetchMock);
render(<ContactForm retentionDays={30} renderedAt={SERVER_RENDERED_AT} />);
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Ada' } });
fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'ada@example.com' } });
fireEvent.change(screen.getByLabelText('Message'), { target: { value: 'Hello there' } });
fireEvent.submit(screen.getByRole('button', { name: 'Send message' }).closest('form')!);
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
return JSON.parse(fetchMock.mock.calls[0][1].body as string) as Record<string, unknown>;
}
describe('ContactForm fill-time stamp', () => {
it('survives a visitor clock running ahead of the server', async () => {
const body = await submitWithClientClock(SERVER_RENDERED_AT + 10 * MINUTE);
// The visitor took two minutes, measured on the server's clock.
expect(validateContact(body, SERVER_RENDERED_AT + 2 * MINUTE).ok).toBe(true);
});
it('survives a visitor clock running a day behind the server', async () => {
const body = await submitWithClientClock(SERVER_RENDERED_AT - 25 * 60 * MINUTE);
expect(validateContact(body, SERVER_RENDERED_AT + 2 * MINUTE).ok).toBe(true);
});
it('still treats an instant submission as spam', async () => {
const body = await submitWithClientClock(SERVER_RENDERED_AT);
expect(validateContact(body, SERVER_RENDERED_AT + 500)).toEqual({ ok: false, reason: 'spam' });
});
});

View File

@@ -0,0 +1,79 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import { parseJournalMarkdown } from '@/lib/journal';
import { escapeHtml } from '@/lib/escapeHtml';
/**
* Most essays have no map block, yet a static import of LeafletMap put its
* wrapper and Leaflet's render-blocking stylesheet on every album, subpage and
* journal page. EssayView now loads it only when a map block renders.
*/
const leafletModuleLoaded = vi.fn();
const mapProps = vi.fn();
vi.mock('@/components/LeafletMap', () => {
leafletModuleLoaded();
return {
LeafletMap: (props: { className?: string; markers: unknown[]; line?: boolean }) => {
mapProps(props);
return <div className={props.className} data-testid="leaflet-map" />;
},
};
});
beforeAll(() => {
// FadeIn reveals at once under reduced motion instead of needing IntersectionObserver.
window.matchMedia = ((query: string) => ({
matches: query.includes('reduce'),
media: query,
addEventListener: () => {},
removeEventListener: () => {},
})) as unknown as typeof window.matchMedia;
});
afterEach(() => cleanup());
describe('EssayView map block', () => {
it('does not load the map module just by being imported', async () => {
await import('@/app/[...path]/EssayView');
expect(leafletModuleLoaded).not.toHaveBeenCalled();
});
it('renders the map on demand with the same markers and escaped popups', async () => {
const { EssayView } = await import('@/app/[...path]/EssayView');
const essay = parseJournalMarkdown(
[
'---',
'title: "Trip"',
'---',
'',
'::map The route',
"Berlin's <b>: 52.52, 13.405",
'48.137, 11.575',
].join('\n'),
);
const block = essay.blocks.find((b) => b.type === 'map');
const label =
block?.type === 'map' && block.items[0].kind === 'point' ? block.items[0].label : '';
expect(label).toBeTruthy();
const { container } = render(<EssayView essay={essay} assets={[]} />);
// Same container the eager version rendered, before and after the load.
expect(container.querySelector('.essay-map')).not.toBeNull();
await screen.findByTestId('leaflet-map');
expect(leafletModuleLoaded).toHaveBeenCalledTimes(1);
expect(container.querySelectorAll('.essay-map')).toHaveLength(1);
const props = mapProps.mock.calls.at(-1)![0];
expect(props.className).toBe('essay-map');
expect(props.line).toBe(true);
expect(props.markers).toHaveLength(2);
expect(props.markers[0]).toMatchObject({ lat: 52.52, lng: 13.405 });
expect(props.markers[0].popupHtml).toContain(`>${escapeHtml(label!)}</h3>`);
expect(props.markers[0].popupHtml).not.toContain('<b>');
expect(props.markers[1].popupHtml).toBeUndefined();
});
});

View File

@@ -0,0 +1,67 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { render, screen, cleanup, fireEvent, act } from '@testing-library/react';
import { HeroCarousel, INTERVAL_MS } from '../HeroCarousel';
vi.mock('next/image', () => ({
default: ({ className, src }: { className?: string; src: string }) => (
// eslint-disable-next-line @next/next/no-img-element
<img alt="" className={className} src={src} />
),
}));
const images = [{ src: '/a' }, { src: '/b' }, { src: '/c' }];
function mockReducedMotion(reduce: boolean) {
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: reduce && query.includes('reduce'),
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}));
}
const activeSrc = (container: HTMLElement) =>
container.querySelector('.hero__carousel-image--active')?.getAttribute('src');
beforeEach(() => vi.useFakeTimers());
afterEach(() => {
cleanup();
vi.useRealTimers();
});
describe('HeroCarousel (#696)', () => {
it('advances on its own by default', () => {
mockReducedMotion(false);
const { container } = render(<HeroCarousel images={images} />);
expect(activeSrc(container)).toBe('/a');
act(() => vi.advanceTimersByTime(INTERVAL_MS));
expect(activeSrc(container)).toBe('/b');
});
it('does not advance under prefers-reduced-motion', () => {
mockReducedMotion(true);
const { container } = render(<HeroCarousel images={images} />);
act(() => vi.advanceTimersByTime(INTERVAL_MS * 3));
expect(activeSrc(container)).toBe('/a');
expect(screen.getByRole('button', { name: 'Play slideshow' })).toBeTruthy();
});
it('stops and restarts with the pause control', () => {
mockReducedMotion(false);
const { container } = render(<HeroCarousel images={images} />);
fireEvent.click(screen.getByRole('button', { name: 'Pause slideshow' }));
act(() => vi.advanceTimersByTime(INTERVAL_MS * 2));
expect(activeSrc(container)).toBe('/a');
fireEvent.click(screen.getByRole('button', { name: 'Play slideshow' }));
act(() => vi.advanceTimersByTime(INTERVAL_MS));
expect(activeSrc(container)).toBe('/b');
});
it('shows no control for a single image', () => {
mockReducedMotion(false);
render(<HeroCarousel images={[images[0]]} />);
expect(screen.queryByRole('button')).toBeNull();
});
});

View File

@@ -0,0 +1,50 @@
// @vitest-environment jsdom
import { describe, it, expect, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';
import { I18nProvider } from '@/components/I18nProvider';
import { LeafletMap, localizedLeafletPrefix } from '@/components/LeafletMap';
import { de } from '@/lib/i18n/locales/de';
/**
* Leaflet ships English control labels: "Zoom in" / "Zoom out" on the zoom
* buttons (also their aria-label) and "A JavaScript library for interactive
* maps" on the credit link. On a German site they now come from the
* dictionary like every other visitor-facing string.
*/
afterEach(() => cleanup());
describe('LeafletMap control labels', () => {
it('takes the zoom button and credit titles from the dictionary', async () => {
const { container } = render(
<I18nProvider locale="de">
<LeafletMap markers={[]} />
</I18nProvider>,
);
const zoomIn = await waitFor(() => {
const el = container.querySelector('.leaflet-control-zoom-in');
if (!el) throw new Error('map not initialised yet');
return el;
});
const zoomOut = container.querySelector('.leaflet-control-zoom-out');
expect(zoomIn.getAttribute('title')).toBe(de.map.zoomIn);
expect(zoomIn.getAttribute('aria-label')).toBe(de.map.zoomIn);
expect(zoomOut?.getAttribute('title')).toBe(de.map.zoomOut);
const credit = container.querySelector('.leaflet-control-attribution a[href*="leafletjs"]');
expect(credit?.getAttribute('title')).toBe(de.map.leafletTitle);
expect(container.innerHTML).not.toContain('Zoom in');
expect(container.innerHTML).not.toContain('A JavaScript library');
});
});
describe('localizedLeafletPrefix', () => {
it('swaps only the title and escapes it', () => {
const prefix = '<a href="https://leafletjs.com" title="English">Leaflet</a>';
expect(localizedLeafletPrefix(prefix, 'Karten "&" mehr')).toBe(
'<a href="https://leafletjs.com" title="Karten &quot;&amp;&quot; mehr">Leaflet</a>',
);
});
});

View File

@@ -0,0 +1,123 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { render, screen, cleanup, fireEvent, act } from '@testing-library/react';
import { MobileNav } from '../MobileNav';
vi.mock('next/navigation', () => ({ usePathname: () => '/' }));
// jsdom lays nothing out, so the header's geometry is stubbed: `navWidth` is
// the space the header offers, `rowWidth` what the links need in one row.
let navWidth = 1440;
let rowWidth = 900;
let phone = false;
let resize: (() => void) | null = null;
beforeEach(() => {
navWidth = 1440;
rowWidth = 900;
phone = false;
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(function (
this: HTMLElement,
) {
return this.tagName === 'NAV' ? navWidth : 0;
});
vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(function (
this: HTMLElement,
) {
return this.tagName === 'NAV' ? Math.max(rowWidth, navWidth) : 0;
});
vi.stubGlobal(
'ResizeObserver',
class {
constructor(cb: () => void) {
resize = cb;
}
observe() {}
disconnect() {}
},
);
vi.stubGlobal('matchMedia', (query: string) => ({
media: query,
get matches() {
return phone;
},
addEventListener() {},
removeEventListener() {},
}));
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
resize = null;
});
function header() {
render(
<nav>
<MobileNav>
<a href="#home">Home</a>
<a href="#about">About</a>
</MobileNav>
</nav>,
);
return {
button: screen.getByRole('button', { name: 'Open menu' }),
panel: document.getElementById('header-nav-panel')!,
};
}
describe('MobileNav collapse (QA P-5)', () => {
it('keeps the links in the row while they fit', () => {
const { button, panel } = header();
expect(button.dataset.collapsed).toBe('false');
expect(panel.dataset.collapsed).toBe('false');
});
it('collapses into the menu when the row is wider than the header', () => {
navWidth = 820;
rowWidth = 1080;
const { button, panel } = header();
expect(button.dataset.collapsed).toBe('true');
expect(panel.dataset.collapsed).toBe('true');
});
it('follows the header width in both directions', () => {
const { button } = header();
expect(button.dataset.collapsed).toBe('false');
navWidth = 820;
rowWidth = 1080;
act(() => resize?.());
expect(button.dataset.collapsed).toBe('true');
// Collapsed, the row is not laid out and cannot be measured; the width
// read before collapsing decides when it fits again.
navWidth = 1440;
act(() => resize?.());
expect(button.dataset.collapsed).toBe('false');
});
it('closes an open panel when the links return to the row', () => {
navWidth = 820;
rowWidth = 1080;
const { button } = header();
fireEvent.click(button);
expect(button.getAttribute('aria-expanded')).toBe('true');
navWidth = 1440;
act(() => resize?.());
expect(button.dataset.collapsed).toBe('false');
expect(button.getAttribute('aria-expanded')).toBe('false');
expect(document.querySelector('.header__nav-backdrop')).toBeNull();
expect(document.body.style.overflow).toBe('');
});
it('always collapses on a phone, whatever the row measures', () => {
phone = true;
rowWidth = 100;
const { button } = header();
expect(button.dataset.collapsed).toBe('true');
});
});

View File

@@ -0,0 +1,65 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
import PasswordGate from '../PasswordGate';
/**
* The gate treated every non-ok answer as a wrong password. After ten attempts
* the rate limit answers 429 — and the visitor who finally typed the right
* password was told it was wrong, and had it wiped from the field.
*/
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
async function submit(response: Response, type?: 'journal') {
vi.stubGlobal(
'fetch',
vi.fn(async () => response),
);
render(<PasswordGate slug="polen" title="Polen" type={type} />);
const input = screen.getByLabelText('Enter password') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'right-password' } });
fireEvent.submit(input.closest('form')!);
const alert = await screen.findByRole('alert');
await waitFor(() => expect(input.disabled).toBe(false));
return { alert, input };
}
describe('PasswordGate refusals', () => {
it('says a wrong password is wrong, and clears the field', async () => {
const { alert, input } = await submit(new Response(null, { status: 401 }));
expect(alert.textContent).toBe('Incorrect password. Please try again.');
expect(input.value).toBe('');
});
it('says a rate limit is a rate limit, with the wait, and keeps the password', async () => {
const { alert, input } = await submit(
new Response(null, { status: 429, headers: { 'Retry-After': '42' } }),
);
expect(alert.textContent).toBe('Too many attempts. Please try again in 42 seconds.');
expect(alert.textContent).not.toContain('Incorrect');
expect(input.value).toBe('right-password');
});
it('copes with a rate limit that names no wait', async () => {
const { alert } = await submit(new Response(null, { status: 429 }));
expect(alert.textContent).toBe('Too many attempts. Please try again later.');
});
it('reports a server failure as a failure, not a wrong password', async () => {
const { alert, input } = await submit(new Response(null, { status: 500 }));
expect(alert.textContent).toBe('Unable to verify password. Please try again later.');
expect(input.value).toBe('right-password');
});
});
describe('PasswordGate subtitle', () => {
it('calls a journal entry a journal entry, not a gallery', () => {
render(<PasswordGate slug="tagebuch" title="Journal" type="journal" />);
expect(screen.getByText('This journal entry is password-protected.')).toBeTruthy();
expect(screen.queryByText('This gallery is password-protected.')).toBeNull();
});
});

View File

@@ -0,0 +1,109 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { render, screen, cleanup, fireEvent, act } from '@testing-library/react';
import { ProofingProvider, useProofing, type ProofSessionInit } from '../ProofingContext';
import { ProofSessionControls } from '../ProofSessionControls';
/**
* The review dialog of a client proofing link. The selection ZIP is built from
* what the server has saved, so it must not be offered while a heart is still
* waiting out the autosave debounce; and the download counter has to follow
* the downloads started from this page.
*/
const TOKEN = 'k'.repeat(32);
function Heart() {
const p = useProofing()!;
return <button onClick={() => p.toggleFavorite('t1')}>heart t1</button>;
}
function renderControls(overrides: Partial<ProofSessionInit> = {}) {
return render(
<ProofingProvider
albumTokens={['t1', 't2', 't3']}
albumName="Wedding"
session={{
token: TOKEN,
selected: ['t3'],
submitted: false,
download: 'album',
downloadsRemaining: null,
...overrides,
}}
>
<Heart />
<ProofSessionControls />
</ProofingProvider>,
);
}
const selectionLink = () => screen.queryByText('Download selection (.zip)');
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.useFakeTimers();
fetchMock = vi.fn(async () => new Response('{}', { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
const store = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, String(v)),
removeItem: (k: string) => void store.delete(k),
clear: () => store.clear(),
});
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe('ProofSessionControls', () => {
it('offers the selection ZIP when nothing is waiting to be saved', () => {
renderControls();
fireEvent.click(screen.getByText('Review & submit'));
expect(selectionLink()).not.toBeNull();
});
it('withholds the selection ZIP while a heart waits out the autosave debounce', async () => {
renderControls();
// A first save, so the state reads 'saved' rather than 'idle'.
fireEvent.click(screen.getByText('heart t1'));
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
expect(fetchMock).toHaveBeenCalledTimes(1);
// Another heart, and the dialog opened before its save is sent.
fireEvent.click(screen.getByText('heart t1'));
fireEvent.click(screen.getByText('Review & submit'));
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(selectionLink()).toBeNull();
// Once the save lands the link comes back, now for the saved selection.
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(selectionLink()).not.toBeNull();
});
it('counts a started download against the limit shown', () => {
renderControls({ downloadsRemaining: 2 });
fireEvent.click(screen.getByText('Review & submit'));
expect(screen.getByText('2 downloads left')).toBeTruthy();
const link = screen.getByText('Download all photos (.zip)');
// jsdom does not navigate; keep it from trying.
link.addEventListener('click', (e) => e.preventDefault());
fireEvent.click(link);
expect(screen.getByText('1 download left')).toBeTruthy();
fireEvent.click(screen.getByText('Download all photos (.zip)'));
expect(screen.queryByText('Download all photos (.zip)')).toBeNull();
expect(selectionLink()).toBeNull();
});
});

View File

@@ -1,19 +1,30 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
import { ProofingModal } from '../ProofingModal';
import { encodeEmail } from '@/lib/emailObfuscation';
/**
* `navigator.clipboard` only exists in a secure context. A portfolio reached
* over plain http on a LAN is not one, and both copy buttons used to throw on
* the missing API and appear dead. They now show the text to copy by hand.
*
* The context is mutable so a test can turn the email action on and off: the
* recipient is `proofing.email`, else the footer contact email (#736), encoded
* until the browser decodes it.
*/
const URL_ = 'http://192.168.1.20:7211/deutschland/kloster-chorin?fav=abc';
const LIST = '#1, #3';
const RECIPIENT = 'hello@example.com';
vi.mock('../ProofingContext', () => ({
useProofing: () => ({
const { proofing } = vi.hoisted(() => ({ proofing: { current: {} as Record<string, unknown> } }));
vi.mock('../ProofingContext', () => ({ useProofing: () => proofing.current }));
/** The context the modal reads; reset per test so flags cannot leak. */
function setContext(overrides: Record<string, unknown> = {}) {
proofing.current = {
favorites: new Set(['a', 'b']),
isModalOpen: true,
setIsModalOpen: vi.fn(),
@@ -22,16 +33,21 @@ vi.mock('../ProofingContext', () => ({
getSelectedTokens: () => ['a', 'b'],
clearFavorites: vi.fn(),
allowMailto: false,
encodedMailto: undefined,
downloadArchiveUrl: undefined,
}),
}));
...overrides,
};
}
const setClipboard = (value: unknown) =>
Object.defineProperty(navigator, 'clipboard', { value, configurable: true });
beforeEach(() => setContext());
afterEach(() => {
cleanup();
setClipboard(undefined);
vi.unstubAllGlobals();
});
describe('ProofingModal copy buttons', () => {
@@ -66,3 +82,42 @@ describe('ProofingModal copy buttons', () => {
expect(screen.queryByLabelText('Copy this link')).toBeNull();
});
});
describe('ProofingModal email to photographer (#736)', () => {
it('hides the email action when no recipient is configured', () => {
setContext({ allowMailto: true, encodedMailto: undefined });
render(<ProofingModal />);
expect(screen.queryByText(/Send Email/)).toBeNull();
});
it('hides the email action when the site turned it off', () => {
setContext({ allowMailto: false, encodedMailto: encodeEmail(RECIPIENT) });
render(<ProofingModal />);
expect(screen.queryByText(/Send Email/)).toBeNull();
});
it('addresses the compose to the configured recipient', () => {
setContext({ allowMailto: true, encodedMailto: encodeEmail(RECIPIENT) });
const location = { href: '' };
vi.stubGlobal('location', location);
render(<ProofingModal />);
fireEvent.click(screen.getByText(/Send Email/));
expect(location.href.startsWith(`mailto:${RECIPIENT}?subject=`)).toBe(true);
});
it('keeps a crafted address from adding its own header fields', () => {
setContext({ allowMailto: true, encodedMailto: encodeEmail('a@b.c?bcc=evil@x.y') });
const location = { href: '' };
vi.stubGlobal('location', location);
render(<ProofingModal />);
fireEvent.click(screen.getByText(/Send Email/));
// The `?` and `=` are escaped, so the draft gains no bcc field.
expect(location.href.startsWith('mailto:a@b.c%3Fbcc%3Devil@x.y?subject=')).toBe(true);
});
it('keeps the plain address out of the rendered markup', () => {
setContext({ allowMailto: true, encodedMailto: encodeEmail(RECIPIENT) });
const { container } = render(<ProofingModal />);
expect(container.innerHTML).not.toContain(RECIPIENT);
});
});

View File

@@ -0,0 +1,125 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { render, screen, cleanup, fireEvent, act } from '@testing-library/react';
import { ProofingProvider, useProofing, type ProofSessionInit } from '../ProofingContext';
/**
* Session mode of the proofing context: the selection starts from the server,
* a burst of hearts becomes one save, and a submitted selection stays put.
*/
function Probe() {
const p = useProofing()!;
return (
<div>
<span data-testid="count">{p.favorites.size}</span>
<span data-testid="state">{p.session?.saveState}</span>
<span data-testid="submitted">{String(p.session?.submitted)}</span>
<button onClick={() => p.toggleFavorite('t1')}>t1</button>
<button onClick={() => p.toggleFavorite('t2')}>t2</button>
<button onClick={() => void p.session?.submit()}>submit</button>
</div>
);
}
const TOKEN = 'k'.repeat(32);
function renderSession(overrides: Partial<ProofSessionInit> = {}) {
return render(
<ProofingProvider
albumTokens={['t1', 't2', 't3']}
albumName="Wedding"
session={{
token: TOKEN,
selected: ['t3'],
submitted: false,
download: 'none',
downloadsRemaining: null,
...overrides,
}}
>
<Probe />
</ProofingProvider>,
);
}
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.useFakeTimers();
fetchMock = vi.fn(async () => new Response('{}', { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
// Node 26 defines its own global localStorage (undefined without
// --localstorage-file), which shadows jsdom's; give the test a working one.
const store = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, String(v)),
removeItem: (k: string) => void store.delete(k),
clear: () => store.clear(),
});
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe('ProofingProvider session mode', () => {
it('starts from the server selection, not localStorage', () => {
localStorage.setItem('folio_fav_wedding', JSON.stringify(['t1', 't2']));
renderSession();
expect(screen.getByTestId('count').textContent).toBe('1');
});
it('debounces hearts into one save, in album order', async () => {
renderSession();
fireEvent.click(screen.getByText('t2'));
fireEvent.click(screen.getByText('t1'));
expect(fetchMock).not.toHaveBeenCalled();
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe(`/api/proof/${TOKEN}/selection`);
expect(init.method).toBe('PUT');
expect(JSON.parse(init.body as string)).toEqual({ assets: ['t1', 't2', 't3'] });
expect(screen.getByTestId('state').textContent).toBe('saved');
});
it('reports a failed save', async () => {
fetchMock.mockResolvedValue(new Response('{}', { status: 500 }));
renderSession();
fireEvent.click(screen.getByText('t1'));
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
expect(screen.getByTestId('state').textContent).toBe('error');
});
it('saves pending hearts before submitting, then locks', async () => {
renderSession();
fireEvent.click(screen.getByText('t1'));
await act(async () => {
fireEvent.click(screen.getByText('submit'));
await vi.advanceTimersByTimeAsync(0);
});
const urls = fetchMock.mock.calls.map((call) => call[0]);
expect(urls).toEqual([`/api/proof/${TOKEN}/selection`, `/api/proof/${TOKEN}/submit`]);
expect(screen.getByTestId('submitted').textContent).toBe('true');
fireEvent.click(screen.getByText('t2'));
expect(screen.getByTestId('count').textContent).toBe('2');
});
it('ignores hearts on a submitted selection', () => {
renderSession({ submitted: true });
fireEvent.click(screen.getByText('t1'));
expect(screen.getByTestId('count').textContent).toBe('1');
});
});

View File

@@ -0,0 +1,148 @@
import { describe, it, expect } from 'vitest';
import fs from 'fs';
import path from 'path';
import { THEME_PRESETS } from '@/lib/config/theme';
/**
* Colours that used to be hard-coded and failed in one mode or with one
* accent (QA T-1, T-2, T-5, T-6, T-8). The values live in app/tokens.css;
* these checks read them from there, so a palette change that breaks one
* fails here.
*/
const read = (...p: string[]) => fs.readFileSync(path.join(process.cwd(), ...p), 'utf8');
const tokens = read('app', 'tokens.css');
type RGBA = { r: number; g: number; b: number; a: number };
function parse(value: string): RGBA {
const v = value.trim();
const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(v)?.[1];
if (hex) {
const h = hex.length === 3 ? [...hex].map((c) => c + c).join('') : hex;
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
a: 1,
};
}
const rgba = /^rgba?\(([^)]+)\)$/.exec(v)?.[1];
if (!rgba) throw new Error(`unparsed colour ${value}`);
const [r, g, b, a = 1] = rgba.split(',').map(Number);
return { r, g, b, a };
}
function over(fg: RGBA, bg: RGBA): RGBA {
const m = (x: number, y: number) => x * fg.a + y * (1 - fg.a);
return { r: m(fg.r, bg.r), g: m(fg.g, bg.g), b: m(fg.b, bg.b), a: 1 };
}
function ratio(a: RGBA, b: RGBA): number {
const lum = (c: RGBA) =>
[c.r, c.g, c.b]
.map((v) => v / 255)
.map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4))
.reduce((sum, v, i) => sum + v * [0.2126, 0.7152, 0.0722][i], 0);
const [x, y] = [lum(a), lum(b)];
return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
}
function block(selector: string): string | undefined {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`(?:^|\\n)${escaped}\\s*\\{([^}]*)\\}`).exec(tokens)?.[1];
}
function token(body: string | undefined, name: string): string | undefined {
return body ? new RegExp(`--${name}:\\s*([^;]+);`).exec(body)?.[1] : undefined;
}
const ROOT = block(':root');
const LIGHT = block("[data-theme='light']");
const SURFACES = ['bg-primary', 'bg-secondary', 'bg-card', 'bg-card-hover'];
/** Every opaque surface token a preset resolves to in one mode. */
function surfaces(preset: string, mode: 'dark' | 'light'): string[] {
const own = block(
mode === 'dark' ? `[data-preset='${preset}']` : `[data-preset='${preset}'][data-theme='light']`,
);
const base = mode === 'dark' ? ROOT : LIGHT;
return SURFACES.map((s) => token(own, s) ?? token(base, s)!).filter((v) => v.startsWith('#'));
}
describe('--error (T-8)', () => {
it('is defined for both modes', () => {
expect(token(ROOT, 'error')).toBeDefined();
expect(token(LIGHT, 'error')).toBeDefined();
});
for (const preset of Object.keys(THEME_PRESETS)) {
for (const mode of ['dark', 'light'] as const) {
it(`${preset} (${mode}): clears 4.5:1 on every surface`, () => {
const error = parse(token(mode === 'dark' ? ROOT : LIGHT, 'error')!);
for (const surface of surfaces(preset, mode)) {
expect(ratio(error, parse(surface)), surface).toBeGreaterThanOrEqual(4.5);
}
});
}
}
it('replaces the fixed reds in the error texts', () => {
for (const file of [
['app', 'contact', 'contact.css'],
['components', 'PasswordGate.module.css'],
['components', 'ProofingModal.tsx'],
]) {
expect(read(...file), file.join('/')).not.toMatch(/#e74c3c|#ff4d4f/i);
}
});
});
describe('lightbox tokens (T-1, T-2)', () => {
const lb = (name: string) => parse(token(ROOT, `lightbox-${name}`)!);
// The two overlays the lightbox paints (the default, and studio-modern's),
// over the lightest page a light preset has — the worst case for white text.
const lightest = parse('#ffffff');
const overlays = [parse('rgba(0, 0, 0, 0.85)'), parse('rgba(10, 10, 10, 0.92)')].map((o) =>
over(o, lightest),
);
it('keeps text and the saved-favourite colour at 4.5:1 on the overlay', () => {
for (const bg of overlays) {
for (const name of ['text', 'text-secondary', 'text-muted', 'fav']) {
expect(ratio(over(lb(name), bg), bg), name).toBeGreaterThanOrEqual(4.5);
}
}
});
it('points the text tokens at them inside the overlay in light mode', () => {
const css = read('components', 'Lightbox.module.css');
const rule = /:global\(\[data-theme='light'\]\) \.overlay \{([^}]*)\}/.exec(css)?.[1];
expect(rule).toBeDefined();
expect(rule).toMatch(/--text-primary:\s*var\(--lightbox-text\)/);
expect(rule).toMatch(/--text-secondary:\s*var\(--lightbox-text-secondary\)/);
expect(rule).toMatch(/--text-muted:\s*var\(--lightbox-text-muted\)/);
});
it('drops the inline favourite colour that overrode the preset', () => {
const tsx = read('components', 'Lightbox.tsx');
expect(tsx).not.toMatch(/#ff4d4f/i);
expect(tsx).not.toMatch(/color: isFav/);
});
});
describe('text on the accent (T-5, T-6)', () => {
it('uses --on-accent instead of a fixed white', () => {
for (const file of [
['components', 'ProofingModal.tsx'],
['components', 'ProofSessionControls.tsx'],
]) {
expect(read(...file), file.join('/')).not.toMatch(/color: '#fff'/);
}
const studioModern = read('app', 'themes', 'studio-modern.css');
const button = /\[data-preset='studio-modern'\] \.empty-state__button \{([^}]*)\}/.exec(
studioModern,
)?.[1];
expect(button).toBeDefined();
expect(button).not.toMatch(/color:\s*#fff/i);
});
});

View File

@@ -0,0 +1,76 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
import { trapTabKey } from '@/lib/focusTrap';
import { MobileNav } from '../MobileNav';
vi.mock('next/navigation', () => ({ usePathname: () => '/' }));
afterEach(() => {
cleanup();
document.body.innerHTML = '';
});
function tab(container: HTMLElement, shiftKey = false) {
const e = new KeyboardEvent('keydown', { key: 'Tab', shiftKey, cancelable: true });
const moved = trapTabKey(container, e);
return { moved, prevented: e.defaultPrevented };
}
describe('trapTabKey (#696)', () => {
function dialog() {
document.body.innerHTML = `
<button id="outside">outside</button>
<div id="dlg">
<button id="a">a</button>
<button id="hidden" aria-hidden="true">x</button>
<a id="b" href="#">b</a>
</div>`;
return document.getElementById('dlg')!;
}
it('wraps Tab from the last element to the first', () => {
const dlg = dialog();
document.getElementById('b')!.focus();
expect(tab(dlg)).toEqual({ moved: true, prevented: true });
expect(document.activeElement?.id).toBe('a');
});
it('wraps Shift+Tab from the first element to the last', () => {
const dlg = dialog();
document.getElementById('a')!.focus();
tab(dlg, true);
expect(document.activeElement?.id).toBe('b');
});
it('leaves Tab between inner elements to the browser', () => {
const dlg = dialog();
document.getElementById('a')!.focus();
expect(tab(dlg)).toEqual({ moved: false, prevented: false });
});
it('pulls escaped focus back in', () => {
const dlg = dialog();
document.getElementById('outside')!.focus();
tab(dlg);
expect(document.activeElement?.id).toBe('a');
});
});
describe('MobileNav focus (#696)', () => {
it('moves focus into the panel and back to the button on Esc', () => {
render(
<MobileNav>
<a href="#home">Home</a>
<a href="#about">About</a>
</MobileNav>,
);
const button = screen.getByRole('button', { name: 'Open menu' });
fireEvent.click(button);
expect(document.activeElement?.textContent).toBe('Home');
fireEvent.keyDown(document, { key: 'Escape' });
expect(document.activeElement).toBe(button);
expect(button.getAttribute('aria-expanded')).toBe('false');
});
});