Skip to content

Commit 89319df

Browse files
fix: patch waitFor functions to trigger a change detection cycle (#96)
Closes #95
1 parent 2d958c5 commit 89319df

File tree

3 files changed

+215
-77
lines changed

3 files changed

+215
-77
lines changed

projects/testing-library/src/lib/testing-library.ts

Lines changed: 131 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import {
99
FireObject,
1010
getQueriesForElement,
1111
prettyDOM,
12-
waitFor,
13-
waitForElementToBeRemoved,
12+
waitFor as dtlWaitFor,
13+
waitForElementToBeRemoved as dtlWaitForElementToBeRemoved,
1414
fireEvent as dtlFireEvent,
1515
screen as dtlScreen,
1616
queries as dtlQueries,
@@ -153,38 +153,22 @@ export async function render<SutType, WrapperType = SutType>(
153153
container?: HTMLElement;
154154
timeout?: number;
155155
interval?: number;
156-
mutationObserverOptions?: {
157-
subtree: boolean;
158-
childList: boolean;
159-
attributes: boolean;
160-
characterData: boolean;
161-
};
156+
mutationObserverOptions?: MutationObserverInit;
162157
} = { container: fixture.nativeElement },
163158
): Promise<T> {
164-
return waitFor<T>(() => {
165-
detectChanges();
166-
return callback();
167-
}, options);
159+
return waitForWrapper(detectChanges, callback, options);
168160
}
169161

170162
function componentWaitForElementToBeRemoved<T>(
171-
callback: () => T,
163+
callback: (() => T) | T,
172164
options: {
173165
container?: HTMLElement;
174166
timeout?: number;
175167
interval?: number;
176-
mutationObserverOptions?: {
177-
subtree: boolean;
178-
childList: boolean;
179-
attributes: boolean;
180-
characterData: boolean;
181-
};
168+
mutationObserverOptions?: MutationObserverInit;
182169
} = { container: fixture.nativeElement },
183170
): Promise<T> {
184-
return waitForElementToBeRemoved<T>(() => {
185-
detectChanges();
186-
return callback();
187-
}, options);
171+
return waitForElementToBeRemovedWrapper(detectChanges, callback, options);
188172
}
189173

190174
return {
@@ -266,28 +250,57 @@ function addAutoImports({ imports, routes }: Pick<RenderComponentOptions<any>, '
266250
return [...imports, ...animations(), ...routing()];
267251
}
268252

269-
// for the findBy queries we first want to run a change detection cycle
270-
function replaceFindWithFindAndDetectChanges<T>(container: HTMLElement, originalQueriesForContainer: T): T {
271-
return Object.keys(originalQueriesForContainer).reduce(
272-
(newQueries, key) => {
273-
if (key.startsWith('find')) {
274-
const getByQuery = dtlQueries[key.replace('find', 'get')];
275-
newQueries[key] = async (text, options, waitForOptions) => {
276-
// original implementation at https://github.com/testing-library/dom-testing-library/blob/master/src/query-helpers.js
277-
const result = await waitFor(() => {
278-
detectChangesForMountedFixtures();
279-
return getByQuery(container, text, options);
280-
}, waitForOptions);
281-
return result;
282-
};
283-
} else {
284-
newQueries[key] = originalQueriesForContainer[key];
253+
/**
254+
* Wrap waitFor to poke the Angular change detection cycle before invoking the callback
255+
*/
256+
async function waitForWrapper<T>(
257+
detectChanges: () => void,
258+
callback: () => T,
259+
options?: {
260+
container?: HTMLElement;
261+
timeout?: number;
262+
interval?: number;
263+
mutationObserverOptions?: MutationObserverInit;
264+
},
265+
): Promise<T> {
266+
return await dtlWaitFor(() => {
267+
detectChanges();
268+
return callback();
269+
}, options);
270+
}
271+
272+
/**
273+
* Wrap waitForElementToBeRemovedWrapper to poke the Angular change detection cycle before invoking the callback
274+
*/
275+
async function waitForElementToBeRemovedWrapper<T>(
276+
detectChanges: () => void,
277+
callback: (() => T) | T,
278+
options?: {
279+
container?: HTMLElement;
280+
timeout?: number;
281+
interval?: number;
282+
mutationObserverOptions?: MutationObserverInit;
283+
},
284+
): Promise<T> {
285+
let cb;
286+
if (typeof callback !== 'function') {
287+
const elements = (Array.isArray(callback) ? callback : [callback]) as HTMLElement[];
288+
const getRemainingElements = elements.map(element => {
289+
let parent = element.parentElement;
290+
while (parent.parentElement) {
291+
parent = parent.parentElement;
285292
}
293+
return () => (parent.contains(element) ? element : null);
294+
});
295+
cb = () => getRemainingElements.map(c => c()).filter(Boolean);
296+
} else {
297+
cb = callback;
298+
}
286299

287-
return newQueries;
288-
},
289-
{} as T,
290-
);
300+
return await dtlWaitForElementToBeRemoved(() => {
301+
detectChanges();
302+
return cb();
303+
}, options);
291304
}
292305

293306
function cleanup() {
@@ -307,11 +320,43 @@ if (typeof afterEach === 'function' && !process.env.ATL_SKIP_AUTO_CLEANUP) {
307320
});
308321
}
309322

323+
/**
324+
* Wrap findBy queries to poke the Angular change detection cycle
325+
*/
326+
function replaceFindWithFindAndDetectChanges<T>(container: HTMLElement, originalQueriesForContainer: T): T {
327+
return Object.keys(originalQueriesForContainer).reduce(
328+
(newQueries, key) => {
329+
if (key.startsWith('find')) {
330+
const getByQuery = dtlQueries[key.replace('find', 'get')];
331+
newQueries[key] = async (text, options, waitForOptions) => {
332+
// original implementation at https://github.com/testing-library/dom-testing-library/blob/master/src/query-helpers.js
333+
const result = await waitForWrapper(
334+
detectChangesForMountedFixtures,
335+
() => getByQuery(container, text, options),
336+
waitForOptions,
337+
);
338+
return result;
339+
};
340+
} else {
341+
newQueries[key] = originalQueriesForContainer[key];
342+
}
343+
344+
return newQueries;
345+
},
346+
{} as T,
347+
);
348+
}
349+
350+
/**
351+
* Call detectChanges for all fixtures
352+
*/
310353
function detectChangesForMountedFixtures() {
311354
mountedFixtures.forEach(fixture => fixture.detectChanges());
312355
}
313356

314-
// wrap dom-fireEvent with a change detection cycle
357+
/**
358+
* Wrap dom-fireEvent to poke the Angular change detection cycle after an event is fired
359+
*/
315360
const fireEvent = Object.keys(dtlFireEvent).reduce(
316361
(events, key) => {
317362
events[key] = (element: HTMLElement, options?: {}) => {
@@ -324,18 +369,55 @@ const fireEvent = Object.keys(dtlFireEvent).reduce(
324369
{} as typeof dtlFireEvent,
325370
);
326371

372+
/**
373+
* Re-export screen with patched queries
374+
*/
327375
const screen = replaceFindWithFindAndDetectChanges(document.body, dtlScreen);
328376

329-
// wrap user-events with the correct fireEvents
377+
/**
378+
* Re-export waitFor with patched waitFor
379+
*/
380+
async function waitFor<T>(
381+
callback: () => T,
382+
options?: {
383+
container?: HTMLElement;
384+
timeout?: number;
385+
interval?: number;
386+
mutationObserverOptions?: MutationObserverInit;
387+
},
388+
): Promise<T> {
389+
return waitForWrapper(detectChangesForMountedFixtures, callback, options);
390+
}
391+
392+
/**
393+
* Re-export waitForElementToBeRemoved with patched waitForElementToBeRemoved
394+
*/
395+
async function waitForElementToBeRemoved<T>(
396+
callback: (() => T) | T,
397+
options?: {
398+
container?: HTMLElement;
399+
timeout?: number;
400+
interval?: number;
401+
mutationObserverOptions?: MutationObserverInit;
402+
},
403+
): Promise<T> {
404+
return waitForElementToBeRemovedWrapper(detectChangesForMountedFixtures, callback, options);
405+
}
406+
407+
/**
408+
* Re-export userEvent with the patched fireEvent
409+
*/
330410
const userEvent = {
331411
type: createType(fireEvent),
332412
selectOptions: createSelectOptions(fireEvent),
333413
tab: tab,
334414
};
335415

336-
// manually export otherwise we get the following error while running Jest tests
337-
// TypeError: Cannot set property fireEvent of [object Object] which has only a getter
338-
// exports.fireEvent = fireEvent;
416+
/**
417+
* Manually export otherwise we get the following error while running Jest tests
418+
* TypeError: Cannot set property fireEvent of [object Object] which has only a getter
419+
* exports.fireEvent = fireEvent
420+
*/
339421
export {
340422
buildQueries,
341423
configure,
@@ -401,12 +483,7 @@ export {
401483
queryAllByAttribute,
402484
queryByAttribute,
403485
queryHelpers,
404-
wait,
405-
waitFor,
406-
waitForDomChange,
407-
waitForElement,
408-
waitForElementToBeRemoved,
409486
within,
410487
} from '@testing-library/dom';
411488

412-
export { fireEvent, screen, userEvent };
489+
export { fireEvent, screen, userEvent, waitFor, waitForElementToBeRemoved };
Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Component, OnInit } from '@angular/core';
2-
import { render } from '../src/public_api';
2+
import { render, screen, waitForElementToBeRemoved as waitForElementToBeRemovedATL } from '../src/public_api';
33
import { timer } from 'rxjs';
44

55
@Component({
@@ -15,18 +15,54 @@ class FixtureComponent implements OnInit {
1515
}
1616
}
1717

18-
test('waits for element to be removed', async () => {
19-
const { queryByTestId, getByTestId, waitForElementToBeRemoved } = await render(FixtureComponent);
18+
describe('from import', () => {
19+
test('waits for element to be removed (callback)', async () => {
20+
await render(FixtureComponent);
2021

21-
await waitForElementToBeRemoved(() => getByTestId('im-here'));
22+
await waitForElementToBeRemovedATL(() => screen.getByTestId('im-here'));
2223

23-
expect(queryByTestId('im-here')).toBeNull();
24+
expect(screen.queryByTestId('im-here')).toBeNull();
25+
});
26+
27+
test('waits for element to be removed (element)', async () => {
28+
await render(FixtureComponent);
29+
30+
await waitForElementToBeRemovedATL(screen.getByTestId('im-here'));
31+
32+
expect(screen.queryByTestId('im-here')).toBeNull();
33+
});
34+
35+
test('allows to override options', async () => {
36+
await render(FixtureComponent);
37+
38+
await expect(waitForElementToBeRemovedATL(() => screen.getByTestId('im-here'), { timeout: 200 })).rejects.toThrow(
39+
/Timed out in waitForElementToBeRemoved/i,
40+
);
41+
});
2442
});
43+
describe('from render', () => {
44+
test('waits for element to be removed (callback)', async () => {
45+
const { queryByTestId, getByTestId, waitForElementToBeRemoved } = await render(FixtureComponent);
46+
47+
await waitForElementToBeRemoved(() => getByTestId('im-here'));
48+
49+
expect(queryByTestId('im-here')).toBeNull();
50+
});
51+
52+
test('waits for element to be removed (element)', async () => {
53+
const { queryByTestId, getByTestId, waitForElementToBeRemoved } = await render(FixtureComponent);
54+
55+
const node = getByTestId('im-here');
56+
await waitForElementToBeRemoved(node);
57+
58+
expect(queryByTestId('im-here')).toBeNull();
59+
});
2560

26-
test('allows to override options', async () => {
27-
const { getByTestId, waitForElementToBeRemoved } = await render(FixtureComponent);
61+
test('allows to override options', async () => {
62+
const { getByTestId, waitForElementToBeRemoved } = await render(FixtureComponent);
2863

29-
await expect(waitForElementToBeRemoved(() => getByTestId('im-here'), { timeout: 200 })).rejects.toThrow(
30-
/Timed out in waitForElementToBeRemoved/i,
31-
);
64+
await expect(waitForElementToBeRemoved(() => getByTestId('im-here'), { timeout: 200 })).rejects.toThrow(
65+
/Timed out in waitForElementToBeRemoved/i,
66+
);
67+
});
3268
});
Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Component } from '@angular/core';
22
import { timer } from 'rxjs';
3-
import { render } from '../src/public_api';
3+
import { render, screen, fireEvent, waitFor as waitForATL } from '../src/public_api';
44

55
@Component({
66
selector: 'fixture',
@@ -17,23 +17,48 @@ class FixtureComponent {
1717
}
1818
}
1919

20-
test('waits for assertion to become true', async () => {
21-
const { queryByText, getByTestId, click, waitFor, getByText } = await render(FixtureComponent);
20+
describe('from import', () => {
21+
test('waits for assertion to become true', async () => {
22+
await render(FixtureComponent);
2223

23-
expect(queryByText('Success')).toBeNull();
24+
expect(screen.queryByText('Success')).toBeNull();
2425

25-
click(getByTestId('button'));
26+
fireEvent.click(screen.getByTestId('button'));
2627

27-
await waitFor(() => getByText('Success'));
28-
getByText('Success');
28+
await waitForATL(() => screen.getByText('Success'));
29+
screen.getByText('Success');
30+
});
31+
32+
test('allows to override options', async () => {
33+
await render(FixtureComponent);
34+
35+
fireEvent.click(screen.getByTestId('button'));
36+
37+
await expect(waitForATL(() => screen.getByText('Success'), { timeout: 200 })).rejects.toThrow(
38+
/Unable to find an element with the text: Success/i,
39+
);
40+
});
2941
});
3042

31-
test('allows to override options', async () => {
32-
const { getByTestId, click, waitFor, getByText } = await render(FixtureComponent);
43+
describe('from render', () => {
44+
test('waits for assertion to become true', async () => {
45+
const { queryByText, getByTestId, click, waitFor, getByText } = await render(FixtureComponent);
46+
47+
expect(queryByText('Success')).toBeNull();
48+
49+
click(getByTestId('button'));
50+
51+
await waitFor(() => getByText('Success'));
52+
getByText('Success');
53+
});
54+
55+
test('allows to override options', async () => {
56+
const { getByTestId, click, waitFor, getByText } = await render(FixtureComponent);
3357

34-
click(getByTestId('button'));
58+
click(getByTestId('button'));
3559

36-
await expect(waitFor(() => getByText('Success'), { timeout: 200 })).rejects.toThrow(
37-
/Unable to find an element with the text: Success/i,
38-
);
60+
await expect(waitFor(() => getByText('Success'), { timeout: 200 })).rejects.toThrow(
61+
/Unable to find an element with the text: Success/i,
62+
);
63+
});
3964
});

0 commit comments

Comments
 (0)