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
committed by marcel
parent e70511a8f8
commit 6c7d696d56

View File

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