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