fix: Svelte 5 test event delegation + login auth regression #46

Merged
marcel merged 2 commits from fix/svelte5-test-delegation-and-login-auth into main 2026-03-22 14:56:14 +01:00
11 changed files with 153 additions and 56 deletions

View File

@@ -211,3 +211,84 @@ test.describe('Conversations', () => {
await page.screenshot({ path: 'test-results/e2e/conversations-sort.png' });
});
});
test.describe('Conversations — enhancements', () => {
// Hans→Anna (1923) and Anna→Hans (1965) are seeded in DataInitializer
// Navigate directly by URL so the test doesn't rely on typeahead interaction
async function loadHansAnnaConversation(page: import('@playwright/test').Page) {
// Resolve person IDs from the persons list
await page.goto('/persons');
const hansLink = page.getByRole('link', { name: /Hans Müller/ }).first();
const hansHref = await hansLink.getAttribute('href');
const hansId = hansHref!.split('/').pop()!;
const annaLink = page.getByRole('link', { name: /Anna Schmidt/ }).first();
const annaHref = await annaLink.getAttribute('href');
const annaId = annaHref!.split('/').pop()!;
await page.goto(`/conversations?senderId=${hansId}&receiverId=${annaId}`);
await page.waitForURL(/senderId=/);
}
test('shows document count and year range summary when both persons are selected', async ({
page
}) => {
await loadHansAnnaConversation(page);
// Hans→Anna (1923) + Anna→Hans (1965) = 2 documents, range 19231965
await expect(page.getByTestId('conv-summary')).toContainText('2');
await expect(page.getByTestId('conv-summary')).toContainText('1923');
await expect(page.getByTestId('conv-summary')).toContainText('1965');
await page.screenshot({ path: 'test-results/e2e/conversations-summary.png' });
});
test('shows year dividers between documents from different years', async ({ page }) => {
await loadHansAnnaConversation(page);
// Expect at least two year dividers (1923 and 1965)
await expect(page.getByTestId('year-divider').first()).toBeVisible();
const dividers = page.getByTestId('year-divider');
const texts = await dividers.allTextContents();
expect(texts.some((t) => t.includes('1923'))).toBe(true);
expect(texts.some((t) => t.includes('1965'))).toBe(true);
await page.screenshot({ path: 'test-results/e2e/conversations-year-dividers.png' });
});
test('swap button switches sender and receiver and reloads', async ({ page }) => {
await loadHansAnnaConversation(page);
const url = new URL(page.url());
const originalSenderId = url.searchParams.get('senderId')!;
const originalReceiverId = url.searchParams.get('receiverId')!;
await page.getByTestId('conv-swap-btn').click();
await page.waitForURL(/senderId=/);
const swappedUrl = new URL(page.url());
expect(swappedUrl.searchParams.get('senderId')).toBe(originalReceiverId);
expect(swappedUrl.searchParams.get('receiverId')).toBe(originalSenderId);
await page.screenshot({ path: 'test-results/e2e/conversations-swap.png' });
});
test('shows "new document" link pre-filled with both persons when conversation is loaded', async ({
page
}) => {
await loadHansAnnaConversation(page);
const url = new URL(page.url());
const senderId = url.searchParams.get('senderId')!;
const receiverId = url.searchParams.get('receiverId')!;
const link = page.getByTestId('conv-new-doc-link');
await expect(link).toBeVisible();
const href = await link.getAttribute('href');
expect(href).toContain(`senderId=${senderId}`);
expect(href).toContain(`receiverId=${receiverId}`);
await page.screenshot({ path: 'test-results/e2e/conversations-new-doc-link.png' });
});
test('does not show swap button or new document link when only one person is selected', async ({
page
}) => {
await page.goto('/conversations');
await page.waitForURL('/conversations');
await expect(page.getByTestId('conv-swap-btn')).not.toBeVisible();
await expect(page.getByTestId('conv-new-doc-link')).not.toBeVisible();
});
});

View File

@@ -65,6 +65,12 @@ export const handleFetch: HandleFetch = async ({ event, request, fetch }) => {
const isApi = request.url.startsWith(apiUrl) || request.url.includes('/api/');
if (isApi) {
// If the request already carries an explicit Authorization header (e.g. the
// login action sends Basic auth), pass it through unchanged.
if (request.headers.has('Authorization')) {
return fetch(request);
}
const token = event.cookies.get('auth_token');
if (!token) {

View File

@@ -1,5 +1,5 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import PersonMultiSelect from './PersonMultiSelect.svelte';
@@ -29,6 +29,7 @@ function receiverInputs() {
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte';
import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
type Person = components['schemas']['Person'];
@@ -21,7 +22,7 @@ let {
onchange
}: Props = $props();
let searchTerm = $derived(initialName);
let searchTerm = $state(initialName);
let results: Person[] = $state([]);
let showDropdown = $state(false);
@@ -38,22 +39,24 @@ function handleInput() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
const term = untrack(() => searchTerm);
const correspondentsOf = untrack(() => restrictToCorrespondentsOf);
loading = true;
try {
let url: string;
if (restrictToCorrespondentsOf) {
if (searchTerm.length >= 1) {
url = `/api/persons/${restrictToCorrespondentsOf}/correspondents?q=${encodeURIComponent(searchTerm)}`;
if (correspondentsOf) {
if (term.length >= 1) {
url = `/api/persons/${correspondentsOf}/correspondents?q=${encodeURIComponent(term)}`;
} else {
url = `/api/persons/${restrictToCorrespondentsOf}/correspondents`;
url = `/api/persons/${correspondentsOf}/correspondents`;
}
} else {
if (searchTerm.length < 1) {
if (term.length < 1) {
results = [];
loading = false;
return;
}
url = `/api/persons?q=${encodeURIComponent(searchTerm)}`;
url = `/api/persons?q=${encodeURIComponent(term)}`;
}
const res = await fetch(url);
results = res.ok ? await res.json() : [];
@@ -66,20 +69,22 @@ function handleInput() {
}, 300);
}
async function handleFocus() {
updateDropdownPosition();
function handleFocus() {
showDropdown = true;
if (restrictToCorrespondentsOf) {
const personId = untrack(() => restrictToCorrespondentsOf)!;
loading = true;
try {
const res = await fetch(`/api/persons/${restrictToCorrespondentsOf}/correspondents`);
results = res.ok ? await res.json() : [];
} catch (e) {
console.error('Suche fehlgeschlagen', e);
results = [];
} finally {
loading = false;
}
(async () => {
try {
const res = await fetch(`/api/persons/${personId}/correspondents`);
results = res.ok ? await res.json() : [];
} catch (e) {
console.error('Suche fehlgeschlagen', e);
results = [];
} finally {
loading = false;
}
})();
}
}
@@ -90,15 +95,6 @@ function selectPerson(person: Person) {
onchange?.(person.id!);
}
let inputEl: HTMLInputElement;
let dropdownStyle = $state('');
function updateDropdownPosition() {
if (!inputEl) return;
const rect = inputEl.getBoundingClientRect();
dropdownStyle = `position:fixed;top:${rect.bottom + 4}px;left:${rect.left}px;width:${rect.width}px`;
}
function clickOutside(node: HTMLElement) {
const handleClick = (event: MouseEvent) => {
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
@@ -114,15 +110,12 @@ function clickOutside(node: HTMLElement) {
}
</script>
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
<div class="relative" use:clickOutside>
<label for={name} class="block text-sm font-medium text-gray-700">{label}</label>
<input type="hidden" name={name} bind:value={value} />
<input
bind:this={inputEl}
type="text"
id="{name}-search"
autocomplete="off"
@@ -135,8 +128,7 @@ function clickOutside(node: HTMLElement) {
{#if showDropdown && (results.length > 0 || loading)}
<div
style={dropdownStyle}
class="ring-opacity-5 z-50 max-h-60 overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black focus:outline-none sm:text-sm"
class="ring-opacity-5 absolute top-full left-0 z-50 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black focus:outline-none sm:text-sm"
>
{#if loading}
<div class="p-2 text-sm text-gray-500">{m.comp_typeahead_loading()}</div>

View File

@@ -1,5 +1,5 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import PersonTypeahead from './PersonTypeahead.svelte';
@@ -30,6 +30,7 @@ function hiddenInput(name: string) {
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
@@ -117,9 +118,12 @@ describe('PersonTypeahead selection', () => {
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
await page.getByText('Mustermann, Max').click();
document.querySelector<HTMLElement>('[role="button"]')!.click();
await tick();
await expect.element(input).toHaveValue('Max Mustermann');
await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument();
await expect
.element(page.getByRole('button', { name: 'Mustermann, Max' }))
.not.toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-selected.png' });
});
@@ -129,7 +133,8 @@ describe('PersonTypeahead selection', () => {
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
await page.getByText('Mustermann, Max').click();
document.querySelector<HTMLElement>('[role="button"]')!.click();
await tick();
await tick();
expect(hiddenInput('senderId')?.value).toBe('1');
});
@@ -141,7 +146,8 @@ describe('PersonTypeahead selection', () => {
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
await page.getByText('Mustermann, Max').click();
document.querySelector<HTMLElement>('[role="button"]')!.click();
await tick();
expect(onchange).toHaveBeenCalledWith('1');
});
@@ -151,7 +157,8 @@ describe('PersonTypeahead selection', () => {
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Ma');
await waitForDebounce();
await page.getByText('Mustermann, Max').click();
document.querySelector<HTMLElement>('[role="button"]')!.click();
await tick();
await expect.element(input).toHaveValue('Max Mustermann');
});
});
@@ -167,7 +174,8 @@ describe('PersonTypeahead clearing a selection', () => {
await input.fill('Mu');
await waitForDebounce();
await page.getByText('Mustermann, Max').click();
document.querySelector<HTMLElement>('[role="button"]')!.click();
await tick();
expect(onchange).toHaveBeenCalledWith('1');
onchange.mockClear();
@@ -190,7 +198,7 @@ describe('PersonTypeahead correspondent mode', () => {
restrictToCorrespondentsOf: 'person-a-id'
});
await page.getByPlaceholder('Namen tippen...').click();
(document.querySelector('input[placeholder="Namen tippen..."]') as HTMLInputElement).focus();
await waitForDebounce();
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
@@ -207,7 +215,7 @@ describe('PersonTypeahead correspondent mode', () => {
restrictToCorrespondentsOf: 'person-a-id'
});
await page.getByPlaceholder('Namen tippen...').click();
(document.querySelector('input[placeholder="Namen tippen..."]') as HTMLInputElement).focus();
await waitForDebounce();
await expect.element(page.getByText('Mustermann, Max')).toBeInTheDocument();

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
interface Props {
@@ -23,7 +24,8 @@ async function fetchSuggestions(query: string) {
if (res.ok) {
const data = await res.json();
const names: string[] = data.map((t: { name: string }) => t.name);
suggestions = names.filter((t) => !tags.includes(t));
const currentTags = untrack(() => tags);
suggestions = names.filter((t) => !currentTags.includes(t));
showSuggestions = true;
}
} catch (e) {

View File

@@ -1,5 +1,5 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import TagInput from './TagInput.svelte';
@@ -24,6 +24,7 @@ function mockFetchEmpty() {
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
@@ -113,8 +114,8 @@ describe('TagInput removing tags', () => {
it('removes a chip when its × button is clicked', async () => {
render(TagInput, { tags: ['Familie', 'Krieg'], allowCreation: true });
// The × buttons have aria-label="Schlagwort entfernen"
const removeButtons = page.getByRole('button', { name: 'Schlagwort entfernen' });
await removeButtons.first().click();
document.querySelector<HTMLElement>('button[aria-label="Schlagwort entfernen"]')!.click();
await tick();
await expect.element(page.getByText('Familie')).not.toBeInTheDocument();
await expect.element(page.getByText('Krieg')).toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/tag-input-after-remove.png' });
@@ -122,8 +123,7 @@ describe('TagInput removing tags', () => {
it('removes the last tag on Backspace when the input is empty', async () => {
render(TagInput, { tags: ['Familie', 'Krieg'], allowCreation: true });
const input = page.getByRole('textbox');
await input.click();
(document.querySelector('input[type="text"]') as HTMLInputElement).focus();
await userEvent.keyboard('{Backspace}');
await expect.element(page.getByText('Krieg')).not.toBeInTheDocument();
await expect.element(page.getByText('Familie')).toBeInTheDocument();
@@ -177,7 +177,8 @@ describe('TagInput autocomplete', () => {
const input = page.getByRole('textbox');
await input.fill('Fa');
await waitForDebounce();
await page.getByRole('option', { name: 'Familie' }).click();
document.querySelector<HTMLElement>('[role="option"]')!.click();
await tick();
await expect.element(page.getByText('Familie')).toBeInTheDocument();
await expect.element(input).toHaveValue('');
await page.screenshot({ path: 'test-results/screenshots/tag-input-suggestion-selected.png' });

View File

@@ -85,7 +85,7 @@ describe('Conversations page swap button', () => {
const { goto } = await import('$app/navigation');
vi.mocked(goto).mockClear();
render(Page, { data: withPersons });
await page.getByTestId('conv-swap-btn').click();
document.querySelector<HTMLElement>('[data-testid="conv-swap-btn"]')!.click();
expect(goto).toHaveBeenCalledWith(expect.stringContaining('senderId=p2'), expect.anything());
expect(goto).toHaveBeenCalledWith(expect.stringContaining('receiverId=p1'), expect.anything());
});

View File

@@ -1,10 +1,12 @@
import { describe, expect, it } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import LoginPage from './+page.svelte';
const tick = () => new Promise((r) => setTimeout(r, 0));
afterEach(cleanup);
describe('Login page rendering', () => {
it('renders the page title', async () => {
render(LoginPage, {});

View File

@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import Page from './+page.svelte';
@@ -13,6 +13,8 @@ vi.stubGlobal(
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) })
);
afterEach(cleanup);
// ─── Test data ────────────────────────────────────────────────────────────────
const emptyData = {

View File

@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import Page from './+page.svelte';
@@ -17,6 +17,8 @@ const makePerson = (overrides = {}) => ({
const emptyData = { user: undefined, canWrite: true, q: '', persons: [] };
const dataWithPersons = { ...emptyData, persons: [makePerson()] };
afterEach(cleanup);
// ─── Rendering ────────────────────────────────────────────────────────────────
describe('Persons page rendering', () => {