/** * PersonMentionEditor — Tiptap-based component tests. * * All old tests used document.querySelector('textarea') which is dead after * the Tiptap migration. These tests drive the contenteditable via * userEvent.type() and inspect the serialized output from the test host. */ import { describe, it, expect, vi, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page, userEvent } from 'vitest/browser'; import { tick } from 'svelte'; import PersonMentionEditorHost from './PersonMentionEditor.test-fixture.svelte'; import type { components } from '$lib/generated/api'; import { m } from '$lib/paraglide/messages.js'; // Single source of truth for the debounce window — imported from the shared // module so the test cannot drift from production. Sara on PR #629 round 3. import { SEARCH_DEBOUNCE_MS } from './mentionConstants'; type Person = components['schemas']['Person']; type PersonMention = components['schemas']['PersonMention']; /** * Headroom above SEARCH_DEBOUNCE_MS for the debounce-window wait * assertions in this file. 350 ms is calibrated against CI-runner jitter * we observed pre-#629; dropping it below ~200 ms reintroduces flake. * See PR #629 round-2 review comment #10935 (Sara). */ const POST_DEBOUNCE_SLACK_MS = 350; const AUGUSTE: Person = { id: 'p-aug', firstName: 'Auguste', lastName: 'Raddatz', displayName: 'Auguste Raddatz', personType: 'PERSON', familyMember: false, birthYear: 1882, deathYear: 1944 }; const ANNA: Person = { id: 'p-anna', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt', personType: 'PERSON', familyMember: false, birthYear: 1860 }; function mockFetchWithPersons(persons: Person[] = [AUGUSTE, ANNA]) { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue(persons) }) ); } function mockFetchEmpty() { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) }) ); } type Snapshot = { value: string; mentionedPersons: PersonMention[] }; function renderHost( initial: { value?: string; mentionedPersons?: PersonMention[]; disabled?: boolean } = {} ) { let snapshot: Snapshot = { value: initial.value ?? '', mentionedPersons: initial.mentionedPersons ?? [] }; render(PersonMentionEditorHost, { initialValue: initial.value ?? '', initialMentions: initial.mentionedPersons ?? [], disabled: initial.disabled ?? false, onChange: (snap: Snapshot) => { snapshot = snap; } }); return { get snapshot() { return snapshot; } }; } afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); // ─── Rendering ──────────────────────────────────────────────────────────────── describe('PersonMentionEditor — rendering', () => { it('renders the editor as a textbox (ARIA role from editorProps)', async () => { render(PersonMentionEditorHost, { initialValue: '', initialMentions: [], onChange: () => {} }); await expect.element(page.getByRole('textbox')).toBeInTheDocument(); }); it('reflects bound initial value as visible text', async () => { render(PersonMentionEditorHost, { initialValue: 'Hallo Welt', initialMentions: [], onChange: () => {} }); await expect.element(page.getByText('Hallo Welt')).toBeInTheDocument(); }); }); // ─── Typeahead opens on @ ───────────────────────────────────────────────────── describe('PersonMentionEditor — typeahead', () => { it('opens the dropdown when typing @ + query and shows results', async () => { mockFetchWithPersons(); renderHost(); await userEvent.type(page.getByRole('textbox'), '@Aug'); await vi.waitFor(async () => { await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument(); }); }); it('hits /api/persons?q= with the typed query', async () => { const fetchMock = vi .fn() .mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) }); vi.stubGlobal('fetch', fetchMock); renderHost(); await userEvent.type(page.getByRole('textbox'), '@Aug'); await vi.waitFor(() => { expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Aug')); }); }); it('appends &limit=5 to the fetch URL (defensive client-side cap, Markus on PR #629)', async () => { const fetchMock = vi .fn() .mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) }); vi.stubGlobal('fetch', fetchMock); renderHost(); await userEvent.type(page.getByRole('textbox'), '@Aug'); await vi.waitFor(() => { expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('limit=5')); }); }); it('shows life dates next to the name in the dropdown', async () => { mockFetchWithPersons(); renderHost(); await userEvent.type(page.getByRole('textbox'), '@Aug'); await vi.waitFor(async () => { await expect.element(page.getByText('* 1882 – † 1944')).toBeInTheDocument(); }); }); it('shows empty state when no persons match', async () => { mockFetchEmpty(); renderHost(); await userEvent.type(page.getByRole('textbox'), '@xyz'); // The visible empty-state
(text-ink-3) shows the copy. The persistent // sr-only aria-live region also contains the same copy, so we scope to the // visible element to avoid a multi-match resolution in expect.element. await vi.waitFor(() => { const visibleEmptyP = document.querySelector( '[role="listbox"] p.text-ink-3' ) as HTMLElement | null; expect(visibleEmptyP).not.toBeNull(); expect(visibleEmptyP!.textContent ?? '').toContain('Keine Personen gefunden'); }); }); it('offers a "create new person" link in the empty state', async () => { mockFetchEmpty(); renderHost(); await userEvent.type(page.getByRole('textbox'), '@xyz'); await vi.waitFor(async () => { const link = page.getByRole('link', { name: /Neue Person anlegen/ }); await expect.element(link).toBeVisible(); await expect.element(link).toHaveAttribute('href', '/persons/new'); }); }); }); // ─── AC-2/3: search input drives the person fetch (debounced) ─────────────── describe('PersonMentionEditor — AC-2/3: search input drives fetch', () => { it('editing the search input fires a debounced fetch with the new query', async () => { const fetchMock = vi .fn() .mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) }); vi.stubGlobal('fetch', fetchMock); renderHost(); // Open the dropdown so the search input is reachable. await userEvent.type(page.getByRole('textbox'), '@'); await vi.waitFor(async () => { await expect.element(page.getByRole('searchbox')).toBeVisible(); }); const fetchesBeforeSearch = fetchMock.mock.calls.length; // `fill` simulates a single input event with the final value — sidesteps // per-keystroke timing of userEvent.type so the test can deterministically // assert that one input event collapses into one debounced fetch. await page.getByRole('searchbox').fill('Walter'); await vi.waitFor( () => { expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('q=Walter')); }, { timeout: 1000 } ); const fetchesAfterSearch = fetchMock.mock.calls.length - fetchesBeforeSearch; expect(fetchesAfterSearch).toBe(1); }); it('fires exactly one /api/persons fetch when the user searches for Walter (regression guard)', async () => { // Regression guard: a previous version of PersonMentionEditor had a // duplicated `items()` callback in the Tiptap suggestion config that // fetched per-keystroke in addition to the debounced search-input fetch // (Markus & Felix round-1). To catch that regression, we must NOT // subtract any baseline — every fetch from render onwards counts. // Sara on PR #629 round 3. const fetchMock = vi .fn() .mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) }); vi.stubGlobal('fetch', fetchMock); renderHost(); // Open the dropdown, then drive the search input via fill() — sidesteps // per-keystroke timing of userEvent.type that Sara flagged round 2. await userEvent.type(page.getByRole('textbox'), '@'); await vi.waitFor(async () => { await expect.element(page.getByRole('searchbox')).toBeVisible(); }); await page.getByRole('searchbox').fill('Walter'); await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS)); // No baseline subtraction — count ALL /api/persons fetches since render. // If the legacy per-keystroke items() callback returns, typing `@` alone // would already produce one fetch and `fill('Walter')` another, breaking // this assertion. const personsFetches = fetchMock.mock.calls.filter( ([url]) => typeof url === 'string' && url.startsWith('/api/persons') ); expect(personsFetches.length).toBe(1); }); it('clearing the search input clears the list without firing a fetch', async () => { const fetchMock = vi .fn() .mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) }); vi.stubGlobal('fetch', fetchMock); renderHost(); await userEvent.type(page.getByRole('textbox'), '@Aug'); await vi.waitFor(async () => { await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument(); }); const fetchesBeforeClear = fetchMock.mock.calls.length; await userEvent.clear(page.getByRole('searchbox')); // Negative assertion: wait past the debounce window to confirm no // trailing fetch was scheduled. Removing this wait would mask a // re-introduction of the keystroke-driven items() fetch. await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS)); expect(fetchMock.mock.calls.length).toBe(fetchesBeforeClear); await expect.element(page.getByText('Auguste Raddatz')).not.toBeInTheDocument(); }); }); // ─── Whitespace-only query (Elicit AC-4 ambiguity on PR #629) ─────────────── describe('PersonMentionEditor — whitespace-only query', () => { it('keeps the "Namen eingeben…" prompt and fires no fetch when @ is followed only by spaces', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) }); vi.stubGlobal('fetch', fetchMock); renderHost(); await userEvent.type(page.getByRole('textbox'), '@ '); await vi.waitFor(async () => { await expect.element(page.getByRole('searchbox')).toBeVisible(); }); await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS)); // Scope to the visible empty-state
(text-ink-3) — the persistent
// sr-only aria-live region above contains the same copy.
const visibleEmptyP = document.querySelector(
'[role="listbox"] p.text-ink-3'
) as HTMLElement | null;
expect(visibleEmptyP).not.toBeNull();
expect(visibleEmptyP!.textContent ?? '').toContain(m.person_mention_search_prompt());
expect(fetchMock).not.toHaveBeenCalled();
});
});
// ─── Stale-response race (Sara on PR #629) ───────────────────────────────────
describe('PersonMentionEditor — stale-response race', () => {
it('discards a stale response that resolves after the search has been cleared', async () => {
let resolveFetch!: (v: { ok: boolean; json: () => Promise (text-ink-3) — the persistent sr-only live region
// above contains the same copy.
const visibleEmptyP = document.querySelector(
'[role="listbox"] p.text-ink-3'
) as HTMLElement | null;
expect(visibleEmptyP).not.toBeNull();
expect(visibleEmptyP!.textContent ?? '').toContain(m.person_mention_popup_empty());
});
it('on a fetch reject (network failure) keeps the dropdown open with the empty-state copy', async () => {
const fetchMock = vi.fn().mockRejectedValue(new TypeError('NetworkError'));
vi.stubGlobal('fetch', fetchMock);
renderHost();
await userEvent.type(page.getByRole('textbox'), '@Aug');
await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));
const visibleEmptyP = document.querySelector(
'[role="listbox"] p.text-ink-3'
) as HTMLElement | null;
expect(visibleEmptyP).not.toBeNull();
expect(visibleEmptyP!.textContent ?? '').toContain(m.person_mention_popup_empty());
});
});
// ─── onExit cancels pending debounce (Felix #1 on PR #629) ───────────────────
describe('PersonMentionEditor — onExit cancels pending debounce', () => {
it('cancels the pending debounced fetch when Escape closes the dropdown before the debounce fires', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) });
vi.stubGlobal('fetch', fetchMock);
renderHost();
// Open the dropdown by typing @ + a query in the editor.
await userEvent.type(page.getByRole('textbox'), '@A');
await vi.waitFor(async () => {
await expect.element(page.getByRole('searchbox')).toBeVisible();
});
// Wait for any in-flight fetch from opening the dropdown to settle.
await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));
const fetchesBeforeEscape = fetchMock.mock.calls.length;
// Trigger a new debounced search (queues runSearch after 150 ms), then
// immediately Escape *while focus is back in the editor* so Tiptap's
// suggestion-plugin Escape handler fires onExit before the debounce.
// Without onExit cancelling the pending debounce, runSearch executes
// against the now-unmounted dropdown's state.
await page.getByRole('searchbox').fill('Walter');
// Focus the editor so the Escape lands on Tiptap's suggestion handler.
(page.getByRole('textbox').element() as HTMLElement).focus();
await userEvent.keyboard('{Escape}');
// Wait past the debounce window. If onExit did not cancel the pending
// debounce, a fetch with q=Walter would still fire here.
await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));
const newFetches = fetchMock.mock.calls.slice(fetchesBeforeEscape);
const walterFetches = newFetches.filter(
([url]) => typeof url === 'string' && url.includes('q=Walter')
);
expect(walterFetches.length).toBe(0);
});
});
// ─── AC-1: search input prefilled with text typed after @ ───────────────────
describe('PersonMentionEditor — AC-1: search input prefill', () => {
it('prefills the dropdown search input with the text typed after @', async () => {
mockFetchEmpty();
renderHost();
await userEvent.type(page.getByRole('textbox'), '@WdG');
await vi.waitFor(async () => {
await expect.element(page.getByRole('searchbox')).toHaveValue('WdG');
});
});
});
// ─── AC-1: typed text becomes displayName, not DB name ───────────────────────
describe('PersonMentionEditor — AC-1: typed text as displayName', () => {
it('stores the typed query as displayName, not the person DB name', async () => {
mockFetchWithPersons();
const host = renderHost();
// User types "@Aug" (not the full "Auguste Raddatz") and selects Auguste Raddatz
await userEvent.type(page.getByRole('textbox'), '@Aug');
await vi.waitFor(async () => {
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
});
// MentionDropdown handles selection via onmousedown (not onclick) to prevent
// blurring the editor before the selection fires. userEvent.click() via CDP
// does not reliably trigger Svelte 5's onmousedown handler when TipTap is
// mounted — dispatch the MouseEvent directly from browser JS instead.
const option = (await page
.getByRole('option', { name: /Auguste Raddatz/ })
.element()) as HTMLElement;
option.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
await vi.waitFor(() => {
expect(host.snapshot.mentionedPersons).toHaveLength(1);
expect(host.snapshot.mentionedPersons[0]).toEqual({
personId: 'p-aug',
displayName: 'Aug' // typed text, not "Auguste Raddatz"
});
});
});
it('regression: text value contains the typed query, not the full DB name', async () => {
mockFetchWithPersons();
const host = renderHost();
await userEvent.type(page.getByRole('textbox'), '@Aug');
await vi.waitFor(async () => {
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
});
await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ }));
await vi.waitFor(() => {
// Text should contain "@Aug " (typed text + space), not "@Auguste Raddatz "
expect(host.snapshot.value).toContain('@Aug');
expect(host.snapshot.value).not.toContain('@Auguste Raddatz');
});
});
it('pushes {personId, displayName} into mentionedPersons sidecar', async () => {
mockFetchWithPersons();
const host = renderHost();
await userEvent.type(page.getByRole('textbox'), '@Aug');
await vi.waitFor(async () => {
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
});
const option = (await page
.getByRole('option', { name: /Auguste Raddatz/ })
.element()) as HTMLElement;
option.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
await vi.waitFor(() => {
expect(host.snapshot.mentionedPersons).toEqual([{ personId: 'p-aug', displayName: 'Aug' }]);
});
});
it('clips the inserted displayName to MAX_QUERY_LENGTH=100 chars (Felix #3 on PR #629)', async () => {
// CWE-400 amplification: the dropdown clips its search input + mirror at
// 100 chars (Nora #1), but the host editor was passing the unclipped
// renderProps.query straight through to displayName — so a 105-char
// @-suffix in the editor could insert a 105-char displayName into the
// sidecar even though the dropdown only searched the first 100.
mockFetchWithPersons();
const host = renderHost();
// Type @ + 105 'A' chars in the contenteditable. The renderProps.query
// fed into the command callback derives from the editor text after `@`,
// not the dropdown's searchbox — so we must drive the editor.
await userEvent.type(page.getByRole('textbox'), '@' + 'A'.repeat(105));
// The mocked /api/persons returns AUGUSTE for any query — wait for it.
await vi.waitFor(async () => {
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
});
const option = (await page
.getByRole('option', { name: /Auguste Raddatz/ })
.element()) as HTMLElement;
option.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
await vi.waitFor(() => {
expect(host.snapshot.mentionedPersons).toHaveLength(1);
// Tight assertion: input is 105 chars, cap is exactly 100. Using
// `toHaveLength(100)` discriminates "clip works" from "clip works
// AND nothing weakened it to e.g. 95". Sara on PR #629 round 4.
expect(host.snapshot.mentionedPersons[0].displayName).toHaveLength(100);
});
});
it('does not duplicate the sidecar entry when the same person is selected twice', async () => {
mockFetchWithPersons();
const host = renderHost({
value: '@Aug ',
mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }]
});
await userEvent.type(page.getByRole('textbox'), '@Aug');
await vi.waitFor(async () => {
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
});
await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ }));
await vi.waitFor(() => {
expect(host.snapshot.mentionedPersons).toHaveLength(1);
});
});
});
// ─── Keyboard navigation ──────────────────────────────────────────────────────
describe('PersonMentionEditor — keyboard navigation', () => {
it('Enter selects the highlighted result', async () => {
mockFetchWithPersons();
const host = renderHost();
await userEvent.type(page.getByRole('textbox'), '@A');
await vi.waitFor(async () => {
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
});
await userEvent.keyboard('{Enter}');
await vi.waitFor(() => {
expect(host.snapshot.mentionedPersons).toHaveLength(1);
});
});
it('ArrowDown moves the highlight to the next result', async () => {
mockFetchWithPersons();
renderHost();
await userEvent.type(page.getByRole('textbox'), '@A');
await vi.waitFor(async () => {
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
});
await userEvent.keyboard('{ArrowDown}');
await vi.waitFor(async () => {
const annaOption = page.getByRole('option', { name: /Anna Schmidt/ });
await expect.element(annaOption).toHaveAttribute('aria-selected', 'true');
});
});
it('Escape closes the dropdown without inserting', async () => {
mockFetchWithPersons();
const host = renderHost();
await userEvent.type(page.getByRole('textbox'), '@Aug');
await vi.waitFor(async () => {
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
});
await userEvent.keyboard('{Escape}');
await vi.waitFor(async () => {
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
});
expect(host.snapshot.mentionedPersons).toEqual([]);
});
});
// ─── Disabled state (WCAG 2.1.1 — keyboard users) ────────────────────────────
describe('PersonMentionEditor — disabled state', () => {
it('sets contenteditable=false on the editor when disabled', async () => {
renderHost({ value: 'Bestehender Text', disabled: true });
await vi.waitFor(() => {
const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null;
expect(textbox).not.toBeNull();
expect(textbox!.getAttribute('contenteditable')).toBe('false');
});
});
it('exposes aria-disabled=true on the editor wrapper when disabled', async () => {
renderHost({ disabled: true });
await vi.waitFor(() => {
const wrapper = document.querySelector('[aria-disabled="true"]');
expect(wrapper).not.toBeNull();
});
});
it('keeps the editor editable (contenteditable=true) when not disabled', async () => {
renderHost({ disabled: false });
await vi.waitFor(() => {
const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null;
expect(textbox).not.toBeNull();
expect(textbox!.getAttribute('contenteditable')).toBe('true');
});
});
});
// ─── Security — XSS in displayName (CWE-79) ──────────────────────────────────
describe('PersonMentionEditor — XSS resistance', () => {
it('renders a malicious displayName as text, not as HTML elements', async () => {
// A historical sidecar entry whose displayName contains an HTML payload
// that would execute if interpolated as raw HTML. Tiptap's renderHTML
// returns the @-prefixed string as the third tuple entry, which
// ProseMirror's DOMSerializer treats as a Text node — escaping it.
const maliciousMention: PersonMention = {
personId: '00000000-0000-0000-0000-000000000001',
displayName: ''
};
renderHost({
value: '@
',
mentionedPersons: [maliciousMention]
});
await vi.waitFor(() => {
const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null;
expect(textbox).not.toBeNull();
// No element from the malicious payload should have appeared as a real
// DOM node. (Tiptap inserts its own ProseMirror-separator
in empty
// paragraphs — that is internal markup and never carries user attrs;
// guard against the injection by checking the user-controlled attrs.)
expect(textbox!.querySelector('img[onerror]')).toBeNull();
expect(textbox!.querySelector('img[src="x"]')).toBeNull();
expect(textbox!.querySelector('script')).toBeNull();
// The payload should appear as visible text content instead.
expect(textbox!.textContent ?? '').toContain('
');
});
});
});
// ─── Placeholder behavior ─────────────────────────────────────────────────────
describe('PersonMentionEditor — placeholder behavior', () => {
it('sets data-placeholder on the inner element when editor is empty', async () => {
render(PersonMentionEditorHost, {
initialValue: '',
initialMentions: [],
placeholder: 'Gib Text ein...',
onChange: () => {}
});
await vi.waitFor(() => {
const inner = document.querySelector('[data-editor-inner]') as HTMLElement | null;
expect(inner).not.toBeNull();
expect(inner!.getAttribute('data-placeholder')).toBe('Gib Text ein...');
});
});
it('omits data-placeholder on the inner element when editor has content', async () => {
render(PersonMentionEditorHost, {
initialValue: 'Bestehender Text',
initialMentions: [],
placeholder: 'Gib Text ein...',
onChange: () => {}
});
await vi.waitFor(() => {
const inner = document.querySelector('[data-editor-inner]') as HTMLElement | null;
expect(inner).not.toBeNull();
expect(inner!.hasAttribute('data-placeholder')).toBe(false);
});
});
});
// ─── i18n message content ─────────────────────────────────────────────────────
describe('PersonMentionEditor — i18n message content', () => {
it('transcription_block_placeholder contains @ mention trigger for discoverability', () => {
expect(m.transcription_block_placeholder()).toContain('@');
});
});
// ─── Touch target (WCAG 2.2 AA) ──────────────────────────────────────────────
describe('PersonMentionEditor — touch target', () => {
it('each result row has min-h-[44px] (WCAG 2.2 AA)', async () => {
mockFetchWithPersons();
renderHost();
await userEvent.type(page.getByRole('textbox'), '@Aug');
await vi.waitFor(async () => {
await expect.element(page.getByRole('option').first()).toBeVisible();
});
const option = document.querySelector('[role="option"]') as HTMLElement;
expect(option).not.toBeNull();
expect(option.className).toContain('min-h-[44px]');
});
});