Fix stale contact suggestions reopening
hide() did not invalidate in-flight search requests. Clearing the input or picking a contact while a request was pending let the old response re-render and pop the suggestion list back open. Bump requestSeq in hide() so every dismissal retires pending responses. Add a Vitest + jsdom harness with black-box tests for the invitee widget, covering the stale-response race both on field clear and on contact pick.
This commit is contained in:
1 parent
e1a3fd12f0
commit
e5432a0a38
6 files changed
+2297
No files matched your search
@@ -0,0 +1,128 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const DEBOUNCE_MS = 250;
|
||||
const SEARCH_URL = '/apps/atocal/contact/search';
|
||||
const BOB = { name: 'Bob', email: 'bob@example.com' };
|
||||
const ZOE = { name: 'Zoe', email: 'zoe@example.com' };
|
||||
|
||||
let input;
|
||||
let list;
|
||||
|
||||
function response(contacts) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(contacts),
|
||||
});
|
||||
}
|
||||
|
||||
/* Promise we can settle manually, to emulate an in-flight request */
|
||||
function pendingResponse() {
|
||||
let resolve;
|
||||
const promise = new Promise((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function type(fragment) {
|
||||
input.value = fragment;
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
/* Let the debounce fire and the promise chain settle */
|
||||
async function settle(ms = DEBOUNCE_MS) {
|
||||
await vi.advanceTimersByTimeAsync(ms);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
document.body.innerHTML =
|
||||
'<span><input id="atocal-attendees" data-search-url="' + SEARCH_URL + '"></span>';
|
||||
|
||||
await import('../../js/invitees.js');
|
||||
|
||||
input = document.getElementById('atocal-attendees');
|
||||
list = document.querySelector('.atocal-suggestions');
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
/* Empty fragment hides the list and cancels pending debounces */
|
||||
type('');
|
||||
await settle(0);
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('invitee suggestions', () => {
|
||||
it('renders matching contacts', async () => {
|
||||
fetch.mockReturnValue(response([BOB]));
|
||||
|
||||
type('bo');
|
||||
await settle();
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(list.hidden).toBe(false);
|
||||
expect(list.querySelectorAll('[role="option"]')).toHaveLength(1);
|
||||
expect(list.textContent).toContain('bob@example.com');
|
||||
expect(input.getAttribute('aria-expanded')).toBe('true');
|
||||
});
|
||||
|
||||
it('ignores responses that arrive after the field was cleared', async () => {
|
||||
const inFlight = pendingResponse();
|
||||
fetch.mockReturnValue(inFlight.promise);
|
||||
|
||||
type('bo');
|
||||
await settle();
|
||||
|
||||
/* User clears the field while the request is still in flight */
|
||||
type('');
|
||||
|
||||
inFlight.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([BOB]),
|
||||
});
|
||||
await settle(0);
|
||||
|
||||
expect(list.hidden).toBe(true);
|
||||
expect(list.children).toHaveLength(0);
|
||||
expect(input.getAttribute('aria-expanded')).toBe('false');
|
||||
});
|
||||
|
||||
it('ignores responses that arrive after a contact was picked', async () => {
|
||||
const second = pendingResponse();
|
||||
fetch
|
||||
.mockReturnValueOnce(response([BOB]))
|
||||
.mockReturnValueOnce(second.promise);
|
||||
|
||||
type('bo');
|
||||
await settle();
|
||||
expect(list.hidden).toBe(false);
|
||||
|
||||
/* Typing more triggers a second request */
|
||||
type('bob');
|
||||
await settle();
|
||||
|
||||
/* User picks the visible suggestion while the request is in flight */
|
||||
list.querySelector('button').dispatchEvent(
|
||||
new MouseEvent('mousedown', { bubbles: true, cancelable: true })
|
||||
);
|
||||
|
||||
expect(input.value).toBe('bob@example.com, ');
|
||||
expect(list.hidden).toBe(true);
|
||||
|
||||
/* The stale response must not reopen the list */
|
||||
second.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([ZOE]),
|
||||
});
|
||||
await settle(0);
|
||||
|
||||
expect(list.hidden).toBe(true);
|
||||
expect(list.children).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user