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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user