test(discussion): rewrite MentionEditor test with vi.waitFor

Replaces 16 setTimeout(350ms / 30ms / 50ms) sleeps with vi.waitFor on
the actual signal — popup listbox appearance/disappearance, option
aria-selected state — so the test no longer races the 200ms internal
debounce against the real clock under CI load.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-11 17:26:49 +02:00
parent efe86a074b
commit b26ae52fbb

View File

@@ -28,6 +28,14 @@ describe('MentionEditor', () => {
fetchSpy?.mockRestore(); fetchSpy?.mockRestore();
}); });
function fireAtMention(ta: HTMLTextAreaElement, text: string) {
ta.focus();
ta.value = text;
ta.selectionStart = text.length;
ta.selectionEnd = text.length;
ta.dispatchEvent(new Event('input', { bubbles: true }));
}
it('renders the textarea with the placeholder', async () => { it('renders the textarea with the placeholder', async () => {
render(MentionEditor, { render(MentionEditor, {
props: { props: {
@@ -75,17 +83,12 @@ describe('MentionEditor', () => {
}); });
const ta = document.querySelector('textarea') as HTMLTextAreaElement; const ta = document.querySelector('textarea') as HTMLTextAreaElement;
ta.focus(); fireAtMention(ta, 'Hi @An');
ta.value = 'Hi @An';
ta.selectionStart = 6;
ta.selectionEnd = 6;
ta.dispatchEvent(new Event('input', { bubbles: true }));
// debounce 200ms + result render // Debounce fires (200ms), fetch resolves, popup opens — vi.waitFor polls until ready.
await new Promise((r) => setTimeout(r, 350)); await vi.waitFor(() => {
expect(document.querySelector('[role="listbox"]')).not.toBeNull();
const popup = document.querySelector('[role="listbox"]'); });
expect(popup).not.toBeNull();
}); });
it('renders the empty-popup label when fetch returns no results', async () => { it('renders the empty-popup label when fetch returns no results', async () => {
@@ -99,13 +102,8 @@ describe('MentionEditor', () => {
}); });
const ta = document.querySelector('textarea') as HTMLTextAreaElement; const ta = document.querySelector('textarea') as HTMLTextAreaElement;
ta.focus(); fireAtMention(ta, '@Zzzz');
ta.value = '@Zzzz';
ta.selectionStart = 5;
ta.selectionEnd = 5;
ta.dispatchEvent(new Event('input', { bubbles: true }));
await new Promise((r) => setTimeout(r, 350));
await expect.element(page.getByText(/keine nutzer gefunden/i)).toBeVisible(); await expect.element(page.getByText(/keine nutzer gefunden/i)).toBeVisible();
}); });
@@ -117,14 +115,8 @@ describe('MentionEditor', () => {
}); });
const ta = document.querySelector('textarea') as HTMLTextAreaElement; const ta = document.querySelector('textarea') as HTMLTextAreaElement;
ta.focus(); fireAtMention(ta, '@Anna');
ta.value = '@Anna';
ta.selectionStart = 5;
ta.selectionEnd = 5;
ta.dispatchEvent(new Event('input', { bubbles: true }));
await new Promise((r) => setTimeout(r, 350));
// Popup is open, but with the empty label
await expect.element(page.getByText(/keine nutzer gefunden/i)).toBeVisible(); await expect.element(page.getByText(/keine nutzer gefunden/i)).toBeVisible();
}); });
@@ -160,18 +152,15 @@ describe('MentionEditor', () => {
}); });
const ta = document.querySelector('textarea') as HTMLTextAreaElement; const ta = document.querySelector('textarea') as HTMLTextAreaElement;
ta.focus(); fireAtMention(ta, '@An');
ta.value = '@An'; await vi.waitFor(() => {
ta.selectionStart = 3; expect(document.querySelector('[role="listbox"]')).not.toBeNull();
ta.selectionEnd = 3; });
ta.dispatchEvent(new Event('input', { bubbles: true }));
await new Promise((r) => setTimeout(r, 350));
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await new Promise((r) => setTimeout(r, 30)); await vi.waitFor(() => {
expect(document.querySelector('[role="listbox"]')).toBeNull();
const popup = document.querySelector('[role="listbox"]'); });
expect(popup).toBeNull();
}); });
it('navigates results with ArrowDown and ArrowUp', async () => { it('navigates results with ArrowDown and ArrowUp', async () => {
@@ -180,26 +169,25 @@ describe('MentionEditor', () => {
}); });
const ta = document.querySelector('textarea') as HTMLTextAreaElement; const ta = document.querySelector('textarea') as HTMLTextAreaElement;
ta.focus(); fireAtMention(ta, '@An');
ta.value = '@An'; await vi.waitFor(() => {
ta.selectionStart = 3; expect(document.querySelectorAll('[role="option"]').length).toBeGreaterThan(1);
ta.selectionEnd = 3; });
ta.dispatchEvent(new Event('input', { bubbles: true }));
await new Promise((r) => setTimeout(r, 350));
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
await new Promise((r) => setTimeout(r, 30)); await vi.waitFor(() => {
const opts = document.querySelectorAll('[role="option"]'); const opts = document.querySelectorAll('[role="option"]');
// Should have moved selection — second item highlighted expect(opts[1]?.getAttribute('aria-selected')).toBe('true');
expect(opts[1]?.getAttribute('aria-selected')).toBe('true'); });
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
await new Promise((r) => setTimeout(r, 30)); await vi.waitFor(() => {
const opts2 = document.querySelectorAll('[role="option"]'); const opts = document.querySelectorAll('[role="option"]');
expect(opts2[0]?.getAttribute('aria-selected')).toBe('true'); expect(opts[0]?.getAttribute('aria-selected')).toBe('true');
});
}); });
it('closes the popup when Enter is hit and no results are present', async () => { it('keeps the popup open when Enter is hit and no results are present', async () => {
fetchSpy.mockImplementationOnce( fetchSpy.mockImplementationOnce(
async () => async () =>
new Response('[]', { status: 200, headers: { 'Content-Type': 'application/json' } }) new Response('[]', { status: 200, headers: { 'Content-Type': 'application/json' } })
@@ -209,67 +197,53 @@ describe('MentionEditor', () => {
}); });
const ta = document.querySelector('textarea') as HTMLTextAreaElement; const ta = document.querySelector('textarea') as HTMLTextAreaElement;
ta.focus(); fireAtMention(ta, '@Zz');
ta.value = '@Zz'; await expect.element(page.getByText(/keine nutzer gefunden/i)).toBeVisible();
ta.selectionStart = 3;
ta.selectionEnd = 3;
ta.dispatchEvent(new Event('input', { bubbles: true }));
await new Promise((r) => setTimeout(r, 350));
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
await new Promise((r) => setTimeout(r, 30)); // Enter with no results does not close the popup and does not submit.
// Should still be open — Enter without results doesn't close, but doesn't onsubmit either expect(document.querySelector('[role="listbox"]')).not.toBeNull();
const popup = document.querySelector('[role="listbox"]');
expect(popup).not.toBeNull();
}); });
it('selects a user via mousedown click and fills the textarea', async () => { it('selects a user via mousedown click and closes the popup', async () => {
render(MentionEditor, { render(MentionEditor, {
props: { value: '', mentionCandidates: [] } props: { value: '', mentionCandidates: [] }
}); });
const ta = document.querySelector('textarea') as HTMLTextAreaElement; const ta = document.querySelector('textarea') as HTMLTextAreaElement;
ta.focus(); fireAtMention(ta, '@An');
ta.value = '@An'; await vi.waitFor(() => {
ta.selectionStart = 3; expect(document.querySelectorAll('[role="option"]').length).toBeGreaterThan(0);
ta.selectionEnd = 3; });
ta.dispatchEvent(new Event('input', { bubbles: true }));
await new Promise((r) => setTimeout(r, 350));
const firstOption = document.querySelector('[role="option"]') as HTMLElement; const firstOption = document.querySelector('[role="option"]') as HTMLElement;
firstOption?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); firstOption.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
await new Promise((r) => setTimeout(r, 50)); await vi.waitFor(() => {
// After select, popup is closed expect(document.querySelector('[role="listbox"]')).toBeNull();
const popup = document.querySelector('[role="listbox"]'); });
expect(popup).toBeNull();
}); });
it('selects via Enter when results are present', async () => { it('selects via Enter when results are present and closes the popup', async () => {
render(MentionEditor, { render(MentionEditor, {
props: { value: '', mentionCandidates: [] } props: { value: '', mentionCandidates: [] }
}); });
const ta = document.querySelector('textarea') as HTMLTextAreaElement; const ta = document.querySelector('textarea') as HTMLTextAreaElement;
ta.focus(); fireAtMention(ta, '@An');
ta.value = '@An'; await vi.waitFor(() => {
ta.selectionStart = 3; expect(document.querySelectorAll('[role="option"]').length).toBeGreaterThan(0);
ta.selectionEnd = 3; });
ta.dispatchEvent(new Event('input', { bubbles: true }));
await new Promise((r) => setTimeout(r, 350));
// Move highlight to first result with ArrowDown then Enter
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
await new Promise((r) => setTimeout(r, 30));
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
await new Promise((r) => setTimeout(r, 50));
// Popup closed after select await vi.waitFor(() => {
const popup = document.querySelector('[role="listbox"]'); expect(document.querySelector('[role="listbox"]')).toBeNull();
expect(popup).toBeNull(); });
}); });
it('handles fetch network throw gracefully', async () => { it('handles fetch network throw gracefully (empty popup label)', async () => {
fetchSpy.mockImplementationOnce(async () => { fetchSpy.mockImplementationOnce(async () => {
throw new Error('network down'); throw new Error('network down');
}); });
@@ -279,14 +253,8 @@ describe('MentionEditor', () => {
}); });
const ta = document.querySelector('textarea') as HTMLTextAreaElement; const ta = document.querySelector('textarea') as HTMLTextAreaElement;
ta.focus(); fireAtMention(ta, '@An');
ta.value = '@An';
ta.selectionStart = 3;
ta.selectionEnd = 3;
ta.dispatchEvent(new Event('input', { bubbles: true }));
await new Promise((r) => setTimeout(r, 350));
// Empty popup label after thrown fetch
await expect.element(page.getByText(/keine nutzer gefunden/i)).toBeVisible(); await expect.element(page.getByText(/keine nutzer gefunden/i)).toBeVisible();
}); });
}); });