fix: Svelte 5 test event delegation + login auth regression #46
@@ -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 1923–1965
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
@@ -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, {});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user