@samitouri / QOS-React-2 / commits / 3cde22a84e

Experimental test selector API (#18607)

Adds several new experimental APIs to aid with automated testing. Each of the methods below accepts an array of "selectors" that identifies a path (or paths) through a React tree. There are four basic selector types: * Component: Matches Fibers with the specified React component type * Role: Matches Host Instances matching the (explicit or implicit) accessibility role. * Test name: Matches Host Instances with a data-testname attribute. * Text: Matches Host Instances that directly contain the specified text. * There is also a special lookahead selector type that enables further matching within a path (without actually including the path in the result). This selector type was inspired by the :has() CSS pseudo-class. It enables e.g. matching a <section> that contained a specific header text, then finding a like button within that <section>. API * findAllNodes(): Finds all Host Instances (e.g. HTMLElement) within a host subtree that match the specified selector criteria. * getFindAllNodesFailureDescription(): Returns an error string describing the matched and unmatched portions of the selector query. * findBoundingRects(): For all React components within a host subtree that match the specified selector criteria, return a set of bounding boxes that covers the bounds of the nearest (shallowed) Host Instances within those trees. * observeVisibleRects(): For all React components within a host subtree that match the specified selector criteria, observe if it’s bounding rect is visible in the viewport and is not occluded. * focusWithin(): For all React components within a host subtree that match the specified selector criteria, set focus within the first focusable Host Instance (as if you started before this component in the tree and moved focus forwards one step).

Brian Vaughn committed May 5, 2020 at 10:37 UTC 3cde22a84e246fc5361f038bf0c23405b2572c22
19 files changed +2466 -8
packages/react-art/src/ReactARTHostConfig.js
+1
@@ -244,6 +244,7 @@ function applyTextProps(instance, props, prevProps = {}) {
244 export * from 'react-reconciler/src/ReactFiberHostConfigWithNoPersistence';
245 export * from 'react-reconciler/src/ReactFiberHostConfigWithNoHydration';
246 export * from 'react-reconciler/src/ReactFiberHostConfigWithNoScopes';
247 +export * from 'react-reconciler/src/ReactFiberHostConfigWithNoTestSelectors';
248
249 export function appendInitialChild(parentInstance, child) {
250 if (typeof child === 'string') {
packages/react-dom/src/__tests__/ReactDOMTestSelectors-test.internal.js new
+1433
@@ -0,0 +1,1433 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @emails react-core
8 + */
9 +
10 +'use strict';
11 +
12 +describe('ReactDOMTestSelectors', () => {
13 + let React;
14 +
15 + let act;
16 + let createComponentSelector;
17 + let createHasPsuedoClassSelector;
18 + let createRoleSelector;
19 + let createTextSelector;
20 + let createTestNameSelector;
21 + let findAllNodes;
22 + let findBoundingRects;
23 + let focusWithin;
24 + let getFindAllNodesFailureDescription;
25 + let observeVisibleRects;
26 + let render;
27 +
28 + let container;
29 +
30 + beforeEach(() => {
31 + jest.resetModules();
32 +
33 + React = require('react');
34 +
35 + const ReactDOM = require('react-dom/testing');
36 + act = ReactDOM.act;
37 + createComponentSelector = ReactDOM.createComponentSelector;
38 + createHasPsuedoClassSelector = ReactDOM.createHasPsuedoClassSelector;
39 + createRoleSelector = ReactDOM.createRoleSelector;
40 + createTextSelector = ReactDOM.createTextSelector;
41 + createTestNameSelector = ReactDOM.createTestNameSelector;
42 + findAllNodes = ReactDOM.findAllNodes;
43 + findBoundingRects = ReactDOM.findBoundingRects;
44 + focusWithin = ReactDOM.focusWithin;
45 + getFindAllNodesFailureDescription =
46 + ReactDOM.getFindAllNodesFailureDescription;
47 + observeVisibleRects = ReactDOM.observeVisibleRects;
48 + render = ReactDOM.render;
49 +
50 + container = document.createElement('div');
51 + document.body.appendChild(container);
52 + });
53 +
54 + afterEach(() => {
55 + document.body.removeChild(container);
56 + });
57 +
58 + describe('findAllNodes', () => {
59 + it('should support searching from the document root', () => {
60 + function Example() {
61 + return (
62 + <div>
63 + <div data-testname="match" id="match" />
64 + </div>
65 + );
66 + }
67 +
68 + render(<Example />, container);
69 +
70 + const matches = findAllNodes(document.body, [
71 + createComponentSelector(Example),
72 + createTestNameSelector('match'),
73 + ]);
74 + expect(matches).toHaveLength(1);
75 + expect(matches[0].id).toBe('match');
76 + });
77 +
78 + it('should support searching from the container', () => {
79 + function Example() {
80 + return (
81 + <div>
82 + <div data-testname="match" id="match" />
83 + </div>
84 + );
85 + }
86 +
87 + render(<Example />, container);
88 +
89 + const matches = findAllNodes(container, [
90 + createComponentSelector(Example),
91 + createTestNameSelector('match'),
92 + ]);
93 + expect(matches).toHaveLength(1);
94 + expect(matches[0].id).toBe('match');
95 + });
96 +
97 + it('should support searching from a previous match if the match had a data-testname', () => {
98 + function Outer() {
99 + return (
100 + <div data-testname="outer" id="outer">
101 + <Inner />
102 + </div>
103 + );
104 + }
105 +
106 + function Inner() {
107 + return <div data-testname="inner" id="inner" />;
108 + }
109 +
110 + render(<Outer />, container);
111 +
112 + let matches = findAllNodes(container, [
113 + createComponentSelector(Outer),
114 + createTestNameSelector('outer'),
115 + ]);
116 + expect(matches).toHaveLength(1);
117 + expect(matches[0].id).toBe('outer');
118 +
119 + matches = findAllNodes(matches[0], [
120 + createComponentSelector(Inner),
121 + createTestNameSelector('inner'),
122 + ]);
123 + expect(matches).toHaveLength(1);
124 + expect(matches[0].id).toBe('inner');
125 + });
126 +
127 + it('should not support searching from a previous match if the match did not have a data-testname', () => {
128 + function Outer() {
129 + return (
130 + <div id="outer">
131 + <Inner />
132 + </div>
133 + );
134 + }
135 +
136 + function Inner() {
137 + return <div id="inner" />;
138 + }
139 +
140 + render(<Outer />, container);
141 +
142 + const matches = findAllNodes(container, [createComponentSelector(Outer)]);
143 + expect(matches).toHaveLength(1);
144 + expect(matches[0].id).toBe('outer');
145 +
146 + expect(() => {
147 + findAllNodes(matches[0], [
148 + createComponentSelector(Inner),
149 + createTestNameSelector('inner'),
150 + ]);
151 + }).toThrow(
152 + 'Invalid host root specified. Should be either a React container or a node with a testname attribute.',
153 + );
154 + });
155 +
156 + it('should support an multiple component types in the selector array', () => {
157 + function Outer() {
158 + return (
159 + <>
160 + <div data-testname="match" id="match1" />
161 + <Middle />
162 + </>
163 + );
164 + }
165 + function Middle() {
166 + return (
167 + <>
168 + <div data-testname="match" id="match2" />
169 + <Inner />
170 + </>
171 + );
172 + }
173 + function Inner() {
174 + return (
175 + <>
176 + <div data-testname="match" id="match3" />
177 + </>
178 + );
179 + }
180 +
181 + render(<Outer />, container);
182 +
183 + let matches = findAllNodes(document.body, [
184 + createComponentSelector(Outer),
185 + createComponentSelector(Middle),
186 + createTestNameSelector('match'),
187 + ]);
188 + expect(matches).toHaveLength(2);
189 + expect(matches.map(m => m.id).sort()).toEqual(['match2', 'match3']);
190 +
191 + matches = findAllNodes(document.body, [
192 + createComponentSelector(Outer),
193 + createComponentSelector(Middle),
194 + createComponentSelector(Inner),
195 + createTestNameSelector('match'),
196 + ]);
197 + expect(matches).toHaveLength(1);
198 + expect(matches[0].id).toBe('match3');
199 +
200 + matches = findAllNodes(document.body, [
201 + createComponentSelector(Outer),
202 + createComponentSelector(Inner),
203 + createTestNameSelector('match'),
204 + ]);
205 + expect(matches).toHaveLength(1);
206 + expect(matches[0].id).toBe('match3');
207 + });
208 +
209 + it('should find multiple matches', () => {
210 + function Example1() {
211 + return (
212 + <div>
213 + <div data-testname="match" id="match1" />
214 + </div>
215 + );
216 + }
217 +
218 + function Example2() {
219 + return (
220 + <div>
221 + <div data-testname="match" id="match2" />
222 + <div data-testname="match" id="match3" />
223 + </div>
224 + );
225 + }
226 +
227 + render(
228 + <>
229 + <Example1 />
230 + <Example2 />
231 + </>,
232 + container,
233 + );
234 +
235 + const matches = findAllNodes(document.body, [
236 + createTestNameSelector('match'),
237 + ]);
238 + expect(matches).toHaveLength(3);
239 + expect(matches.map(m => m.id).sort()).toEqual([
240 + 'match1',
241 + 'match2',
242 + 'match3',
243 + ]);
244 + });
245 +
246 + it('should ignore nested matches', () => {
247 + function Example() {
248 + return (
249 + <div data-testname="match" id="match1">
250 + <div data-testname="match" id="match2" />
251 + </div>
252 + );
253 + }
254 +
255 + render(<Example />, container);
256 +
257 + const matches = findAllNodes(document.body, [
258 + createComponentSelector(Example),
259 + createTestNameSelector('match'),
260 + ]);
261 + expect(matches).toHaveLength(1);
262 + expect(matches[0].id).toEqual('match1');
263 + });
264 +
265 + it('should enforce the specific order of selectors', () => {
266 + function Outer() {
267 + return (
268 + <>
269 + <div data-testname="match" id="match1" />
270 + <Inner />
271 + </>
272 + );
273 + }
274 + function Inner() {
275 + return <div data-testname="match" id="match1" />;
276 + }
277 +
278 + render(<Outer />, container);
279 +
280 + expect(
281 + findAllNodes(document.body, [
282 + createComponentSelector(Inner),
283 + createComponentSelector(Outer),
284 + createTestNameSelector('match'),
285 + ]),
286 + ).toHaveLength(0);
287 + });
288 +
289 + it('should not search within hidden subtrees', () => {
290 + const ref1 = React.createRef(null);
291 + const ref2 = React.createRef(null);
292 +
293 + function Outer() {
294 + return (
295 + <>
296 + <div hidden={true}>
297 + <div ref={ref1} data-testname="match" />
298 + </div>
299 + <Inner />
300 + </>
301 + );
302 + }
303 + function Inner() {
304 + return <div ref={ref2} data-testname="match" />;
305 + }
306 +
307 + render(<Outer />, container);
308 +
309 + const matches = findAllNodes(document.body, [
310 + createComponentSelector(Outer),
311 + createTestNameSelector('match'),
312 + ]);
313 +
314 + expect(matches).toHaveLength(1);
315 + expect(matches[0]).toBe(ref2.current);
316 + });
317 +
318 + it('should support filtering by display text', () => {
319 + function Example() {
320 + return (
321 + <div>
322 + <div>foo</div>
323 + <div>
324 + <div id="match">bar</div>
325 + </div>
326 + </div>
327 + );
328 + }
329 +
330 + render(<Example />, container);
331 +
332 + const matches = findAllNodes(document.body, [
333 + createComponentSelector(Example),
334 + createTextSelector('bar'),
335 + ]);
336 + expect(matches).toHaveLength(1);
337 + expect(matches[0].id).toBe('match');
338 + });
339 +
340 + it('should support filtering by explicit accessibiliy role', () => {
341 + function Example() {
342 + return (
343 + <div>
344 + <div>foo</div>
345 + <div>
346 + <div role="button" id="match">
347 + bar
348 + </div>
349 + </div>
350 + </div>
351 + );
352 + }
353 +
354 + render(<Example />, container);
355 +
356 + const matches = findAllNodes(document.body, [
357 + createComponentSelector(Example),
358 + createRoleSelector('button'),
359 + ]);
360 + expect(matches).toHaveLength(1);
361 + expect(matches[0].id).toBe('match');
362 + });
363 +
364 + it('should support filtering by explicit secondary accessibiliy role', () => {
365 + const ref = React.createRef();
366 +
367 + function Example() {
368 + return (
369 + <div>
370 + <div>foo</div>
371 + <div>
372 + <div ref={ref} role="meter progressbar" />
373 + </div>
374 + </div>
375 + );
376 + }
377 +
378 + render(<Example />, container);
379 +
380 + const matches = findAllNodes(document.body, [
381 + createComponentSelector(Example),
382 + createRoleSelector('progressbar'),
383 + ]);
384 + expect(matches).toHaveLength(1);
385 + expect(matches[0]).toBe(ref.current);
386 + });
387 +
388 + it('should support filtering by implicit accessibiliy role', () => {
389 + function Example() {
390 + return (
391 + <div>
392 + <div>foo</div>
393 + <div>
394 + <button id="match">bar</button>
395 + </div>
396 + </div>
397 + );
398 + }
399 +
400 + render(<Example />, container);
401 +
402 + const matches = findAllNodes(document.body, [
403 + createComponentSelector(Example),
404 + createRoleSelector('button'),
405 + ]);
406 + expect(matches).toHaveLength(1);
407 + expect(matches[0].id).toBe('match');
408 + });
409 +
410 + it('should support filtering by implicit accessibiliy role with attributes qualifications', () => {
411 + function Example() {
412 + return (
413 + <div>
414 + <div>foo</div>
415 + <div>
416 + <input type="checkbox" id="match" value="bar" />
417 + </div>
418 + </div>
419 + );
420 + }
421 +
422 + render(<Example />, container);
423 +
424 + const matches = findAllNodes(document.body, [
425 + createComponentSelector(Example),
426 + createRoleSelector('checkbox'),
427 + ]);
428 + expect(matches).toHaveLength(1);
429 + expect(matches[0].id).toBe('match');
430 + });
431 +
432 + it('should support searching ahead with the has() selector', () => {
433 + function Example() {
434 + return (
435 + <div>
436 + <article>
437 + <h1>Should match</h1>
438 + <p>
439 + <button id="match">Like</button>
440 + </p>
441 + </article>
442 + <article>
443 + <h1>Should not match</h1>
444 + <p>
445 + <button>Like</button>
446 + </p>
447 + </article>
448 + </div>
449 + );
450 + }
451 +
452 + render(<Example />, container);
453 +
454 + const matches = findAllNodes(document.body, [
455 + createComponentSelector(Example),
456 + createRoleSelector('article'),
457 + createHasPsuedoClassSelector([
458 + createRoleSelector('heading'),
459 + createTextSelector('Should match'),
460 + ]),
461 + createRoleSelector('button'),
462 + ]);
463 + expect(matches).toHaveLength(1);
464 + expect(matches[0].id).toBe('match');
465 + });
466 +
467 + it('should throw if no container can be found', () => {
468 + expect(() => findAllNodes(document.body, [])).toThrow(
469 + 'Could not find React container within specified host subtree.',
470 + );
471 + });
472 +
473 + it('should throw if an invalid host root is specified', () => {
474 + const ref = React.createRef();
475 + function Example() {
476 + return <div ref={ref} />;
477 + }
478 +
479 + render(<Example />, container);
480 +
481 + expect(() => findAllNodes(ref.current, [])).toThrow(
482 + 'Invalid host root specified. Should be either a React container or a node with a testname attribute.',
483 + );
484 + });
485 + });
486 +
487 + describe('getFindAllNodesFailureDescription', () => {
488 + it('should describe findAllNodes failures caused by the component type selector', () => {
489 + function Outer() {
490 + return <Middle />;
491 + }
492 + function Middle() {
493 + return <div />;
494 + }
495 + function NotRendered() {
496 + return <div data-testname="match" />;
497 + }
498 +
499 + render(<Outer />, container);
500 +
501 + const description = getFindAllNodesFailureDescription(document.body, [
502 + createComponentSelector(Outer),
503 + createComponentSelector(Middle),
504 + createComponentSelector(NotRendered),
505 + createTestNameSelector('match'),
506 + ]);
507 +
508 + expect(description).toEqual(
509 + `findAllNodes was able to match part of the selector:
510 + <Outer> > <Middle>
511 +
512 +No matching component was found for:
513 + <NotRendered> > [data-testname="match"]`,
514 + );
515 + });
516 +
517 + it('should return null if findAllNodes was able to find a match', () => {
518 + function Example() {
519 + return (
520 + <div>
521 + <div data-testname="match" id="match" />
522 + </div>
523 + );
524 + }
525 +
526 + render(<Example />, container);
527 +
528 + const description = getFindAllNodesFailureDescription(document.body, [
529 + createComponentSelector(Example),
530 + ]);
531 +
532 + expect(description).toBe(null);
533 + });
534 + });
535 +
536 + describe('findBoundingRects', () => {
537 + // Stub out getBoundingClientRect for the specified target.
538 + // This API is required by the test selectors but it isn't implemented by jsdom.
539 + function setBoundingClientRect(target, {x, y, width, height}) {
540 + target.getBoundingClientRect = function() {
541 + return {
542 + width,
543 + height,
544 + left: x,
545 + right: x + width,
546 + top: y,
547 + bottom: y + height,
548 + };
549 + };
550 + }
551 +
552 + it('should return a single rect for a component that returns a single root host element', () => {
553 + const ref = React.createRef();
554 +
555 + function Example() {
556 + return (
557 + <div ref={ref}>
558 + <div />
559 + <div />
560 + </div>
561 + );
562 + }
563 +
564 + render(<Example />, container);
565 +
566 + setBoundingClientRect(ref.current, {
567 + x: 10,
568 + y: 20,
569 + width: 200,
570 + height: 100,
571 + });
572 +
573 + const rects = findBoundingRects(document.body, [
574 + createComponentSelector(Example),
575 + ]);
576 + expect(rects).toHaveLength(1);
577 + expect(rects).toContainEqual({
578 + x: 10,
579 + y: 20,
580 + width: 200,
581 + height: 100,
582 + });
583 + });
584 +
585 + it('should return a multiple rects for multiple matches', () => {
586 + const outerRef = React.createRef();
587 + const innerRef = React.createRef();
588 +
589 + function Outer() {
590 + return (
591 + <>
592 + <div ref={outerRef} />
593 + <Inner />
594 + </>
595 + );
596 + }
597 + function Inner() {
598 + return <div ref={innerRef} />;
599 + }
600 +
601 + render(<Outer />, container);
602 +
603 + setBoundingClientRect(outerRef.current, {
604 + x: 10,
605 + y: 20,
606 + width: 200,
607 + height: 100,
608 + });
609 + setBoundingClientRect(innerRef.current, {
610 + x: 110,
611 + y: 120,
612 + width: 250,
613 + height: 150,
614 + });
615 +
616 + const rects = findBoundingRects(document.body, [
617 + createComponentSelector(Outer),
618 + ]);
619 + expect(rects).toHaveLength(2);
620 + expect(rects).toContainEqual({
621 + x: 10,
622 + y: 20,
623 + width: 200,
624 + height: 100,
625 + });
626 + expect(rects).toContainEqual({
627 + x: 110,
628 + y: 120,
629 + width: 250,
630 + height: 150,
631 + });
632 + });
633 +
634 + it('should return a multiple rects for single match that returns a fragment', () => {
635 + const refA = React.createRef();
636 + const refB = React.createRef();
637 +
638 + function Example() {
639 + return (
640 + <>
641 + <div ref={refA}>
642 + <div />
643 + <div />
644 + </div>
645 + <div ref={refB} />
646 + </>
647 + );
648 + }
649 +
650 + render(<Example />, container);
651 +
652 + setBoundingClientRect(refA.current, {
653 + x: 10,
654 + y: 20,
655 + width: 200,
656 + height: 100,
657 + });
658 + setBoundingClientRect(refB.current, {
659 + x: 110,
660 + y: 120,
661 + width: 250,
662 + height: 150,
663 + });
664 +
665 + const rects = findBoundingRects(document.body, [
666 + createComponentSelector(Example),
667 + ]);
668 + expect(rects).toHaveLength(2);
669 + expect(rects).toContainEqual({
670 + x: 10,
671 + y: 20,
672 + width: 200,
673 + height: 100,
674 + });
675 + expect(rects).toContainEqual({
676 + x: 110,
677 + y: 120,
678 + width: 250,
679 + height: 150,
680 + });
681 + });
682 +
683 + it('should merge overlapping rects', () => {
684 + const refA = React.createRef();
685 + const refB = React.createRef();
686 + const refC = React.createRef();
687 +
688 + function Example() {
689 + return (
690 + <>
691 + <div ref={refA} />
692 + <div ref={refB} />
693 + <div ref={refC} />
694 + </>
695 + );
696 + }
697 +
698 + render(<Example />, container);
699 +
700 + setBoundingClientRect(refA.current, {
701 + x: 10,
702 + y: 10,
703 + width: 50,
704 + height: 25,
705 + });
706 + setBoundingClientRect(refB.current, {
707 + x: 10,
708 + y: 10,
709 + width: 20,
710 + height: 10,
711 + });
712 + setBoundingClientRect(refC.current, {
713 + x: 100,
714 + y: 10,
715 + width: 50,
716 + height: 25,
717 + });
718 +
719 + const rects = findBoundingRects(document.body, [
720 + createComponentSelector(Example),
721 + ]);
722 + expect(rects).toHaveLength(2);
723 + expect(rects).toContainEqual({
724 + x: 10,
725 + y: 10,
726 + width: 50,
727 + height: 25,
728 + });
729 + expect(rects).toContainEqual({
730 + x: 100,
731 + y: 10,
732 + width: 50,
733 + height: 25,
734 + });
735 + });
736 +
737 + it('should merge some types of adjacent rects (if they are the same in one dimension)', () => {
738 + const refA = React.createRef();
739 + const refB = React.createRef();
740 + const refC = React.createRef();
741 + const refD = React.createRef();
742 + const refE = React.createRef();
743 + const refF = React.createRef();
744 + const refG = React.createRef();
745 +
746 + function Example() {
747 + return (
748 + <>
749 + <div ref={refA} data-debug="A" />
750 + <div ref={refB} data-debug="B" />
751 + <div ref={refC} data-debug="C" />
752 + <div ref={refD} data-debug="D" />
753 + <div ref={refE} data-debug="E" />
754 + <div ref={refF} data-debug="F" />
755 + <div ref={refG} data-debug="G" />
756 + </>
757 + );
758 + }
759 +
760 + render(<Example />, container);
761 +
762 + // A, B, and C are all adjacent and/or overlapping, with the same height.
763 + setBoundingClientRect(refA.current, {
764 + x: 30,
765 + y: 0,
766 + width: 40,
767 + height: 25,
768 + });
769 + setBoundingClientRect(refB.current, {
770 + x: 0,
771 + y: 0,
772 + width: 50,
773 + height: 25,
774 + });
775 + setBoundingClientRect(refC.current, {
776 + x: 70,
777 + y: 0,
778 + width: 20,
779 + height: 25,
780 + });
781 +
782 + // D is partially overlapping with A and B, but is too tall to be merged.
783 + setBoundingClientRect(refD.current, {
784 + x: 20,
785 + y: 0,
786 + width: 20,
787 + height: 30,
788 + });
789 +
790 + // Same thing but for a vertical group.
791 + // Some of them could intersect with the horizontal group,
792 + // except they're too far to the right.
793 + setBoundingClientRect(refE.current, {
794 + x: 100,
795 + y: 25,
796 + width: 25,
797 + height: 50,
798 + });
799 + setBoundingClientRect(refF.current, {
800 + x: 100,
801 + y: 0,
802 + width: 25,
803 + height: 25,
804 + });
805 + setBoundingClientRect(refG.current, {
806 + x: 100,
807 + y: 75,
808 + width: 25,
809 + height: 10,
810 + });
811 +
812 + const rects = findBoundingRects(document.body, [
813 + createComponentSelector(Example),
814 + ]);
815 + expect(rects).toHaveLength(3);
816 + expect(rects).toContainEqual({
817 + x: 0,
818 + y: 0,
819 + width: 90,
820 + height: 25,
821 + });
822 + expect(rects).toContainEqual({
823 + x: 20,
824 + y: 0,
825 + width: 20,
826 + height: 30,
827 + });
828 + expect(rects).toContainEqual({
829 + x: 100,
830 + y: 0,
831 + width: 25,
832 + height: 85,
833 + });
834 + });
835 +
836 + it('should not search within hidden subtrees', () => {
837 + const refA = React.createRef();
838 + const refB = React.createRef();
839 + const refC = React.createRef();
840 +
841 + function Example() {
842 + return (
843 + <>
844 + <div ref={refA} />
845 + <div hidden={true} ref={refB} />
846 + <div ref={refC} />
847 + </>
848 + );
849 + }
850 +
851 + render(<Example />, container);
852 +
853 + setBoundingClientRect(refA.current, {
854 + x: 10,
855 + y: 10,
856 + width: 50,
857 + height: 25,
858 + });
859 + setBoundingClientRect(refB.current, {
860 + x: 100,
861 + y: 10,
862 + width: 20,
863 + height: 10,
864 + });
865 + setBoundingClientRect(refC.current, {
866 + x: 200,
867 + y: 10,
868 + width: 50,
869 + height: 25,
870 + });
871 +
872 + const rects = findBoundingRects(document.body, [
873 + createComponentSelector(Example),
874 + ]);
875 + expect(rects).toHaveLength(2);
876 + expect(rects).toContainEqual({
877 + x: 10,
878 + y: 10,
879 + width: 50,
880 + height: 25,
881 + });
882 + expect(rects).toContainEqual({
883 + x: 200,
884 + y: 10,
885 + width: 50,
886 + height: 25,
887 + });
888 + });
889 + });
890 +
891 + describe('focusWithin', () => {
892 + it('should return false if the specified component path has no matches', () => {
893 + function Example() {
894 + return <Child />;
895 + }
896 + function Child() {
897 + return null;
898 + }
899 + function NotUsed() {
900 + return null;
901 + }
902 +
903 + render(<Example />, container);
904 +
905 + const didFocus = focusWithin(document.body, [
906 + createComponentSelector(Example),
907 + createComponentSelector(NotUsed),
908 + ]);
909 + expect(didFocus).toBe(false);
910 + });
911 +
912 + it('should return false if there are no focusable elements within the matched subtree', () => {
913 + function Example() {
914 + return <Child />;
915 + }
916 + function Child() {
917 + return 'not focusable';
918 + }
919 +
920 + render(<Example />, container);
921 +
922 + const didFocus = focusWithin(document.body, [
923 + createComponentSelector(Example),
924 + createComponentSelector(Child),
925 + ]);
926 + expect(didFocus).toBe(false);
927 + });
928 +
929 + it('should return false if the only focusable elements are disabled', () => {
930 + function Example() {
931 + return (
932 + <button disabled={true} style={{width: 10, height: 10}}>
933 + not clickable
934 + </button>
935 + );
936 + }
937 +
938 + render(<Example />, container);
939 +
940 + const didFocus = focusWithin(document.body, [
941 + createComponentSelector(Example),
942 + ]);
943 + expect(didFocus).toBe(false);
944 + });
945 +
946 + it('should return false if the only focusable elements are hidden', () => {
947 + function Example() {
948 + return <button hidden={true}>not clickable</button>;
949 + }
950 +
951 + render(<Example />, container);
952 +
953 + const didFocus = focusWithin(document.body, [
954 + createComponentSelector(Example),
955 + ]);
956 + expect(didFocus).toBe(false);
957 + });
958 +
959 + it('should successfully focus the first focusable element within the tree', () => {
960 + const secondRef = React.createRef(null);
961 +
962 + const handleFirstFocus = jest.fn();
963 + const handleSecondFocus = jest.fn();
964 + const handleThirdFocus = jest.fn();
965 +
966 + function Example() {
967 + return (
968 + <>
969 + <FirstChild />
970 + <SecondChild />
971 + <ThirdChild />
972 + </>
973 + );
974 + }
975 + function FirstChild() {
976 + return (
977 + <button hidden={true} onFocus={handleFirstFocus}>
978 + not clickable
979 + </button>
980 + );
981 + }
982 + function SecondChild() {
983 + return (
984 + <button
985 + ref={secondRef}
986 + style={{width: 10, height: 10}}
987 + onFocus={handleSecondFocus}>
988 + clickable
989 + </button>
990 + );
991 + }
992 + function ThirdChild() {
993 + return (
994 + <button style={{width: 10, height: 10}} onFocus={handleThirdFocus}>
995 + clickable
996 + </button>
997 + );
998 + }
999 +
1000 + render(<Example />, container);
1001 +
1002 + const didFocus = focusWithin(document.body, [
1003 + createComponentSelector(Example),
1004 + ]);
1005 + expect(didFocus).toBe(true);
1006 + expect(document.activeElement).not.toBeNull();
1007 + expect(document.activeElement).toBe(secondRef.current);
1008 + expect(handleFirstFocus).not.toHaveBeenCalled();
1009 + expect(handleSecondFocus).toHaveBeenCalledTimes(1);
1010 + expect(handleThirdFocus).not.toHaveBeenCalled();
1011 + });
1012 +
1013 + it('should successfully focus the first focusable element even if application logic interferes', () => {
1014 + const ref = React.createRef(null);
1015 +
1016 + const handleFocus = jest.fn(event => {
1017 + event.target.blur();
1018 + });
1019 +
1020 + function Example() {
1021 + return (
1022 + <button
1023 + ref={ref}
1024 + style={{width: 10, height: 10}}
1025 + onFocus={handleFocus}>
1026 + clickable
1027 + </button>
1028 + );
1029 + }
1030 +
1031 + render(<Example />, container);
1032 +
1033 + const didFocus = focusWithin(document.body, [
1034 + createComponentSelector(Example),
1035 + ]);
1036 + expect(didFocus).toBe(true);
1037 + expect(ref.current).not.toBeNull();
1038 + expect(ref.current).not.toBe(document.activeElement);
1039 + expect(handleFocus).toHaveBeenCalledTimes(1);
1040 + });
1041 +
1042 + it('should not focus within hidden subtrees', () => {
1043 + const secondRef = React.createRef(null);
1044 +
1045 + const handleFirstFocus = jest.fn();
1046 + const handleSecondFocus = jest.fn();
1047 + const handleThirdFocus = jest.fn();
1048 +
1049 + function Example() {
1050 + return (
1051 + <>
1052 + <FirstChild />
1053 + <SecondChild />
1054 + <ThirdChild />
1055 + </>
1056 + );
1057 + }
1058 + function FirstChild() {
1059 + return (
1060 + <div hidden={true}>
1061 + <button style={{width: 10, height: 10}} onFocus={handleFirstFocus}>
1062 + hidden
1063 + </button>
1064 + </div>
1065 + );
1066 + }
1067 + function SecondChild() {
1068 + return (
1069 + <button
1070 + ref={secondRef}
1071 + style={{width: 10, height: 10}}
1072 + onFocus={handleSecondFocus}>
1073 + clickable
1074 + </button>
1075 + );
1076 + }
1077 + function ThirdChild() {
1078 + return (
1079 + <button style={{width: 10, height: 10}} onFocus={handleThirdFocus}>
1080 + clickable
1081 + </button>
1082 + );
1083 + }
1084 +
1085 + render(<Example />, container);
1086 +
1087 + const didFocus = focusWithin(document.body, [
1088 + createComponentSelector(Example),
1089 + ]);
1090 + expect(didFocus).toBe(true);
1091 + expect(document.activeElement).not.toBeNull();
1092 + expect(document.activeElement).toBe(secondRef.current);
1093 + expect(handleFirstFocus).not.toHaveBeenCalled();
1094 + expect(handleSecondFocus).toHaveBeenCalledTimes(1);
1095 + expect(handleThirdFocus).not.toHaveBeenCalled();
1096 + });
1097 + });
1098 +
1099 + describe('observeVisibleRects', () => {
1100 + // Stub out getBoundingClientRect for the specified target.
1101 + // This API is required by the test selectors but it isn't implemented by jsdom.
1102 + function setBoundingClientRect(target, {x, y, width, height}) {
1103 + target.getBoundingClientRect = function() {
1104 + return {
1105 + width,
1106 + height,
1107 + left: x,
1108 + right: x + width,
1109 + top: y,
1110 + bottom: y + height,
1111 + };
1112 + };
1113 + }
1114 +
1115 + function simulateIntersection(...entries) {
1116 + callback(
1117 + entries.map(([target, rect, ratio]) => ({
1118 + boundingClientRect: {
1119 + top: rect.y,
1120 + left: rect.x,
1121 + width: rect.width,
1122 + height: rect.height,
1123 + },
1124 + intersectionRatio: ratio,
1125 + target,
1126 + })),
1127 + );
1128 + }
1129 +
1130 + let callback;
1131 + let observedTargets;
1132 +
1133 + beforeEach(() => {
1134 + callback = null;
1135 + observedTargets = [];
1136 +
1137 + class IntersectionObserver {
1138 + constructor() {
1139 + callback = arguments[0];
1140 + }
1141 +
1142 + disconnect() {
1143 + callback = null;
1144 + observedTargets.splice(0);
1145 + }
1146 +
1147 + observe(target) {
1148 + observedTargets.push(target);
1149 + }
1150 +
1151 + unobserve(target) {
1152 + const index = observedTargets.indexOf(target);
1153 + if (index >= 0) {
1154 + observedTargets.splice(index, 1);
1155 + }
1156 + }
1157 + }
1158 +
1159 + // This is a broken polyfill.
1160 + // It is only intended to provide bare minimum test coverage.
1161 + // More meaningful tests will require the use of fixtures.
1162 + window.IntersectionObserver = IntersectionObserver;
1163 + });
1164 +
1165 + it('should notify a listener when the underlying instance intersection changes', () => {
1166 + const ref = React.createRef(null);
1167 +
1168 + function Example() {
1169 + return <div ref={ref} />;
1170 + }
1171 +
1172 + render(<Example />, container);
1173 +
1174 + // Stub out the size of the element this test will be observing.
1175 + const rect = {
1176 + x: 10,
1177 + y: 20,
1178 + width: 200,
1179 + height: 100,
1180 + };
1181 + setBoundingClientRect(ref.current, rect);
1182 +
1183 + const handleVisibilityChange = jest.fn();
1184 + observeVisibleRects(
1185 + document.body,
1186 + [createComponentSelector(Example)],
1187 + handleVisibilityChange,
1188 + );
1189 +
1190 + expect(callback).not.toBeNull();
1191 + expect(observedTargets).toHaveLength(1);
1192 + expect(handleVisibilityChange).not.toHaveBeenCalled();
1193 +
1194 + // Simulate IntersectionObserver notification.
1195 + simulateIntersection([ref.current, rect, 0.5]);
1196 +
1197 + expect(handleVisibilityChange).toHaveBeenCalledTimes(1);
1198 + expect(handleVisibilityChange).toHaveBeenCalledWith([{rect, ratio: 0.5}]);
1199 + });
1200 +
1201 + it('should notify a listener of multiple targets when the underlying instance intersection changes', () => {
1202 + const ref1 = React.createRef(null);
1203 + const ref2 = React.createRef(null);
1204 +
1205 + function Example() {
1206 + return (
1207 + <>
1208 + <div ref={ref1} />
1209 + <div ref={ref2} />
1210 + </>
1211 + );
1212 + }
1213 +
1214 + render(<Example />, container);
1215 +
1216 + // Stub out the size of the element this test will be observing.
1217 + const rect1 = {
1218 + x: 10,
1219 + y: 20,
1220 + width: 200,
1221 + height: 100,
1222 + };
1223 + let rect2 = {
1224 + x: 210,
1225 + y: 20,
1226 + width: 200,
1227 + height: 100,
1228 + };
1229 + setBoundingClientRect(ref1.current, rect1);
1230 + setBoundingClientRect(ref2.current, rect2);
1231 +
1232 + const handleVisibilityChange = jest.fn();
1233 + observeVisibleRects(
1234 + document.body,
1235 + [createComponentSelector(Example)],
1236 + handleVisibilityChange,
1237 + );
1238 +
1239 + expect(callback).not.toBeNull();
1240 + expect(observedTargets).toHaveLength(2);
1241 + expect(handleVisibilityChange).not.toHaveBeenCalled();
1242 +
1243 + // Simulate IntersectionObserver notification.
1244 + simulateIntersection([ref1.current, rect1, 0.5]);
1245 +
1246 + // Even though only one of the rects changed intersection,
1247 + // the test selector should describe the current state of both.
1248 + expect(handleVisibilityChange).toHaveBeenCalledTimes(1);
1249 + expect(handleVisibilityChange).toHaveBeenCalledWith([
1250 + {rect: rect1, ratio: 0.5},
1251 + {rect: rect2, ratio: 0},
1252 + ]);
1253 +
1254 + handleVisibilityChange.mockClear();
1255 +
1256 + rect2 = {
1257 + x: 210,
1258 + y: 20,
1259 + width: 200,
1260 + height: 200,
1261 + };
1262 +
1263 + // Simulate another IntersectionObserver notification.
1264 + simulateIntersection(
1265 + [ref1.current, rect1, 1],
1266 + [ref2.current, rect2, 0.25],
1267 + );
1268 +
1269 + // The newly changed display rect should also be provided for the second target.
1270 + expect(handleVisibilityChange).toHaveBeenCalledTimes(1);
1271 + expect(handleVisibilityChange).toHaveBeenCalledWith([
1272 + {rect: rect1, ratio: 1},
1273 + {rect: rect2, ratio: 0.25},
1274 + ]);
1275 + });
1276 +
1277 + it('should stop listening when its disconnected', () => {
1278 + const ref = React.createRef(null);
1279 +
1280 + function Example() {
1281 + return <div ref={ref} />;
1282 + }
1283 +
1284 + render(<Example />, container);
1285 +
1286 + // Stub out the size of the element this test will be observing.
1287 + const rect = {
1288 + x: 10,
1289 + y: 20,
1290 + width: 200,
1291 + height: 100,
1292 + };
1293 + setBoundingClientRect(ref.current, rect);
1294 +
1295 + const handleVisibilityChange = jest.fn();
1296 + const {disconnect} = observeVisibleRects(
1297 + document.body,
1298 + [createComponentSelector(Example)],
1299 + handleVisibilityChange,
1300 + );
1301 +
1302 + expect(callback).not.toBeNull();
1303 + expect(observedTargets).toHaveLength(1);
1304 + expect(handleVisibilityChange).not.toHaveBeenCalled();
1305 +
1306 + disconnect();
1307 + expect(callback).toBeNull();
1308 + });
1309 +
1310 + // This test reuires gating because it relies on the __DEV__ only commit hook to work.
1311 + // @gate __DEV__
1312 + it('should update which targets its listening to after a commit', () => {
1313 + const ref1 = React.createRef(null);
1314 + const ref2 = React.createRef(null);
1315 +
1316 + let increment;
1317 +
1318 + function Example() {
1319 + const [count, setCount] = React.useState(0);
1320 + increment = () => setCount(count + 1);
1321 + return (
1322 + <>
1323 + {count < 2 && <div ref={ref1} />}
1324 + {count > 0 && <div ref={ref2} />}
1325 + </>
1326 + );
1327 + }
1328 +
1329 + render(<Example />, container);
1330 +
1331 + // Stub out the size of the element this test will be observing.
1332 + const rect1 = {
1333 + x: 10,
1334 + y: 20,
1335 + width: 200,
1336 + height: 100,
1337 + };
1338 + setBoundingClientRect(ref1.current, rect1);
1339 +
1340 + const handleVisibilityChange = jest.fn();
1341 + observeVisibleRects(
1342 + document.body,
1343 + [createComponentSelector(Example)],
1344 + handleVisibilityChange,
1345 + );
1346 +
1347 + // Simulate IntersectionObserver notification.
1348 + simulateIntersection([ref1.current, rect1, 1]);
1349 +
1350 + expect(handleVisibilityChange).toHaveBeenCalledTimes(1);
1351 + expect(handleVisibilityChange).toHaveBeenCalledWith([
1352 + {rect: rect1, ratio: 1},
1353 + ]);
1354 +
1355 + act(() => increment());
1356 +
1357 + const rect2 = {
1358 + x: 110,
1359 + y: 20,
1360 + width: 200,
1361 + height: 100,
1362 + };
1363 + setBoundingClientRect(ref2.current, rect2);
1364 +
1365 + handleVisibilityChange.mockClear();
1366 +
1367 + simulateIntersection(
1368 + [ref1.current, rect1, 0.5],
1369 + [ref2.current, rect2, 0.25],
1370 + );
1371 +
1372 + expect(handleVisibilityChange).toHaveBeenCalledTimes(1);
1373 + expect(handleVisibilityChange).toHaveBeenCalledWith([
1374 + {rect: rect1, ratio: 0.5},
1375 + {rect: rect2, ratio: 0.25},
1376 + ]);
1377 +
1378 + act(() => increment());
1379 +
1380 + handleVisibilityChange.mockClear();
1381 +
1382 + simulateIntersection([ref2.current, rect2, 0.75]);
1383 +
1384 + expect(handleVisibilityChange).toHaveBeenCalledTimes(1);
1385 + expect(handleVisibilityChange).toHaveBeenCalledWith([
1386 + {rect: rect2, ratio: 0.75},
1387 + ]);
1388 + });
1389 +
1390 + it('should not observe components within hidden subtrees', () => {
1391 + const ref1 = React.createRef(null);
1392 + const ref2 = React.createRef(null);
1393 +
1394 + function Example() {
1395 + return (
1396 + <>
1397 + <div ref={ref1} />
1398 + <div hidden={true} ref={ref2} />
1399 + </>
1400 + );
1401 + }
1402 +
1403 + render(<Example />, container);
1404 +
1405 + // Stub out the size of the element this test will be observing.
1406 + const rect1 = {
1407 + x: 10,
1408 + y: 20,
1409 + width: 200,
1410 + height: 100,
1411 + };
1412 + const rect2 = {
1413 + x: 210,
1414 + y: 20,
1415 + width: 200,
1416 + height: 100,
1417 + };
1418 + setBoundingClientRect(ref1.current, rect1);
1419 + setBoundingClientRect(ref2.current, rect2);
1420 +
1421 + const handleVisibilityChange = jest.fn();
1422 + observeVisibleRects(
1423 + document.body,
1424 + [createComponentSelector(Example)],
1425 + handleVisibilityChange,
1426 + );
1427 +
1428 + expect(callback).not.toBeNull();
1429 + expect(observedTargets).toHaveLength(1);
1430 + expect(observedTargets[0]).toBe(ref1.current);
1431 + });
1432 + });
1433 +});
packages/react-dom/src/client/DOMAccessibilityRoles.js new
+139
@@ -0,0 +1,139 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +// Below code forked from dom-accessibility-api
11 +
12 +const tagToRoleMappings = {
13 + ARTICLE: 'article',
14 + ASIDE: 'complementary',
15 + BODY: 'document',
16 + BUTTON: 'button',
17 + DATALIST: 'listbox',
18 + DD: 'definition',
19 + DETAILS: 'group',
20 + DIALOG: 'dialog',
21 + DT: 'term',
22 + FIELDSET: 'group',
23 + FIGURE: 'figure',
24 + // WARNING: Only with an accessible name
25 + FORM: 'form',
26 + FOOTER: 'contentinfo',
27 + H1: 'heading',
28 + H2: 'heading',
29 + H3: 'heading',
30 + H4: 'heading',
31 + H5: 'heading',
32 + H6: 'heading',
33 + HEADER: 'banner',
34 + HR: 'separator',
35 + LEGEND: 'legend',
36 + LI: 'listitem',
37 + MATH: 'math',
38 + MAIN: 'main',
39 + MENU: 'list',
40 + NAV: 'navigation',
41 + OL: 'list',
42 + OPTGROUP: 'group',
43 + // WARNING: Only in certain context
44 + OPTION: 'option',
45 + OUTPUT: 'status',
46 + PROGRESS: 'progressbar',
47 + // WARNING: Only with an accessible name
48 + SECTION: 'region',
49 + SUMMARY: 'button',
50 + TABLE: 'table',
51 + TBODY: 'rowgroup',
52 + TEXTAREA: 'textbox',
53 + TFOOT: 'rowgroup',
54 + // WARNING: Only in certain context
55 + TD: 'cell',
56 + TH: 'columnheader',
57 + THEAD: 'rowgroup',
58 + TR: 'row',
59 + UL: 'list',
60 +};
61 +
62 +function getImplicitRole(element: Element): string | null {
63 + const mappedByTag = tagToRoleMappings[element.tagName];
64 + if (mappedByTag !== undefined) {
65 + return mappedByTag;
66 + }
67 +
68 + switch (element.tagName) {
69 + case 'A':
70 + case 'AREA':
71 + case 'LINK':
72 + if (element.hasAttribute('href')) {
73 + return 'link';
74 + }
75 + break;
76 + case 'IMG':
77 + if ((element.getAttribute('alt') || '').length > 0) {
78 + return 'img';
79 + }
80 + break;
81 + case 'INPUT': {
82 + const type = (element: any).type;
83 + switch (type) {
84 + case 'button':
85 + case 'image':
86 + case 'reset':
87 + case 'submit':
88 + return 'button';
89 + case 'checkbox':
90 + case 'radio':
91 + return type;
92 + case 'range':
93 + return 'slider';
94 + case 'email':
95 + case 'tel':
96 + case 'text':
97 + case 'url':
98 + if (element.hasAttribute('list')) {
99 + return 'combobox';
100 + }
101 + return 'textbox';
102 + case 'search':
103 + if (element.hasAttribute('list')) {
104 + return 'combobox';
105 + }
106 + return 'searchbox';
107 + default:
108 + return null;
109 + }
110 + }
111 +
112 + case 'SELECT':
113 + if (element.hasAttribute('multiple') || (element: any).size > 1) {
114 + return 'listbox';
115 + }
116 + return 'combobox';
117 + }
118 +
119 + return null;
120 +}
121 +
122 +function getExplicitRoles(element: Element): Array<string> | null {
123 + const role = element.getAttribute('role');
124 + if (role) {
125 + return role.trim().split(' ');
126 + }
127 +
128 + return null;
129 +}
130 +
131 +// https://w3c.github.io/html-aria/#document-conformance-requirements-for-use-of-aria-attributes-in-html
132 +export function hasRole(element: Element, role: string): boolean {
133 + const explicitRoles = getExplicitRoles(element);
134 + if (explicitRoles !== null && explicitRoles.indexOf(role) >= 0) {
135 + return true;
136 + }
137 +
138 + return role === getImplicitRole(element);
139 +}
packages/react-dom/src/client/ReactDOMHostConfig.js
+149
@@ -8,6 +8,12 @@
8 */
9
10 import type {TopLevelType} from 'legacy-events/TopLevelEventTypes';
11 +import type {Fiber, FiberRoot} from 'react-reconciler/src/ReactInternalTypes';
12 +import type {
13 + BoundingRect,
14 + IntersectionObserverOptions,
15 + ObserveVisibleRectsCallback,
16 +} from 'react-reconciler/src/ReactTestSelectors';
17 import type {RootType} from './ReactDOMRoot';
18 import type {ReactScopeInstance} from 'shared/ReactTypes';
19 import type {
@@ -21,7 +27,10 @@ import {
27 updateFiberProps,
28 getClosestInstanceFromNode,
29 getFiberFromScopeInstance,
30 + getInstanceFromNode as getInstanceFromNodeDOMTree,
31 + isContainerMarkedAsRoot,
32 } from './ReactDOMComponentTree';
33 +import {hasRole} from './DOMAccessibilityRoles';
34 import {
35 createElement,
36 createTextNode,
@@ -69,6 +78,7 @@ import {
78 enableModernEventSystem,
79 enableScopeAPI,
80 } from 'shared/ReactFeatureFlags';
81 +import {HostComponent, HostText} from 'react-reconciler/src/ReactWorkTags';
82 import {TOP_BEFORE_BLUR, TOP_AFTER_BLUR} from '../events/DOMTopLevelEventTypes';
83 import {listenToEvent} from '../events/DOMModernPluginEventSystem';
84
@@ -1142,3 +1152,142 @@ export function getInstanceFromScope(
1152 }
1153 return null;
1154 }
1155 +
1156 +export const supportsTestSelectors = true;
1157 +
1158 +export function findFiberRoot(node: Instance): null | FiberRoot {
1159 + const stack = [node];
1160 + let index = 0;
1161 + while (index < stack.length) {
1162 + const current = stack[index++];
1163 + if (isContainerMarkedAsRoot(current)) {
1164 + return ((getInstanceFromNodeDOMTree(current): any): FiberRoot);
1165 + }
1166 + stack.push(...current.children);
1167 + }
1168 + return null;
1169 +}
1170 +
1171 +export function getBoundingRect(node: Instance): BoundingRect {
1172 + const rect = node.getBoundingClientRect();
1173 + return {
1174 + x: rect.left,
1175 + y: rect.top,
1176 + width: rect.width,
1177 + height: rect.height,
1178 + };
1179 +}
1180 +
1181 +export function matchAccessibilityRole(node: Instance, role: string): boolean {
1182 + if (hasRole(node, role)) {
1183 + return true;
1184 + }
1185 +
1186 + return false;
1187 +}
1188 +
1189 +export function getTextContent(fiber: Fiber): string | null {
1190 + switch (fiber.tag) {
1191 + case HostComponent:
1192 + let textContent = '';
1193 + const childNodes = fiber.stateNode.childNodes;
1194 + for (let i = 0; i < childNodes.length; i++) {
1195 + const childNode = childNodes[i];
1196 + if (childNode.nodeType === Node.TEXT_NODE) {
1197 + textContent += childNode.textContent;
1198 + }
1199 + }
1200 + return textContent;
1201 + case HostText:
1202 + return fiber.stateNode.textContent;
1203 + }
1204 +
1205 + return null;
1206 +}
1207 +
1208 +export function isHiddenSubtree(fiber: Fiber): boolean {
1209 + return fiber.tag === HostComponent && fiber.memoizedProps.hidden === true;
1210 +}
1211 +
1212 +export function setFocusIfFocusable(node: Instance): boolean {
1213 + // The logic for determining if an element is focusable is kind of complex,
1214 + // and since we want to actually change focus anyway- we can just skip it.
1215 + // Instead we'll just listen for a "focus" event to verify that focus was set.
1216 + //
1217 + // We could compare the node to document.activeElement after focus,
1218 + // but this would not handle the case where application code managed focus to automatically blur.
1219 + let didFocus = false;
1220 + const handleFocus = () => {
1221 + didFocus = true;
1222 + };
1223 +
1224 + const element = ((node: any): HTMLElement);
1225 + try {
1226 + element.addEventListener('focus', handleFocus);
1227 + (element.focus || HTMLElement.prototype.focus).call(element);
1228 + } finally {
1229 + element.removeEventListener('focus', handleFocus);
1230 + }
1231 +
1232 + return didFocus;
1233 +}
1234 +
1235 +type RectRatio = {
1236 + ratio: number,
1237 + rect: BoundingRect,
1238 +};
1239 +
1240 +export function setupIntersectionObserver(
1241 + targets: Array<Instance>,
1242 + callback: ObserveVisibleRectsCallback,
1243 + options?: IntersectionObserverOptions,
1244 +): {|
1245 + disconnect: () => void,
1246 + observe: (instance: Instance) => void,
1247 + unobserve: (instance: Instance) => void,
1248 +|} {
1249 + const rectRatioCache: Map<Instance, RectRatio> = new Map();
1250 + targets.forEach(target => {
1251 + rectRatioCache.set(target, {
1252 + rect: getBoundingRect(target),
1253 + ratio: 0,
1254 + });
1255 + });
1256 +
1257 + const handleIntersection = (entries: Array<IntersectionObserverEntry>) => {
1258 + entries.forEach(entry => {
1259 + const {boundingClientRect, intersectionRatio, target} = entry;
1260 + rectRatioCache.set(target, {
1261 + rect: {
1262 + x: boundingClientRect.left,
1263 + y: boundingClientRect.top,
1264 + width: boundingClientRect.width,
1265 + height: boundingClientRect.height,
1266 + },
1267 + ratio: intersectionRatio,
1268 + });
1269 + });
1270 +
1271 + callback(Array.from(rectRatioCache.values()));
1272 + };
1273 +
1274 + const observer = new IntersectionObserver(handleIntersection, options);
1275 + targets.forEach(target => {
1276 + observer.observe((target: any));
1277 + });
1278 +
1279 + return {
1280 + disconnect: () => observer.disconnect(),
1281 + observe: target => {
1282 + rectRatioCache.set(target, {
1283 + rect: getBoundingRect(target),
1284 + ratio: 0,
1285 + });
1286 + observer.observe((target: any));
1287 + },
1288 + unobserve: target => {
1289 + rectRatioCache.delete(target);
1290 + observer.unobserve((target: any));
1291 + },
1292 + };
1293 +}
packages/react-dom/testing.classic.fb.js
+13 -1
@@ -8,4 +8,16 @@
8 */
9
10 export * from './index.classic.fb.js';
11 -export {act} from 'react-reconciler/src/ReactFiberReconciler';
11 +export {
12 + act,
13 + createComponentSelector,
14 + createHasPsuedoClassSelector,
15 + createRoleSelector,
16 + createTestNameSelector,
17 + createTextSelector,
18 + getFindAllNodesFailureDescription,
19 + findAllNodes,
20 + findBoundingRects,
21 + focusWithin,
22 + observeVisibleRects,
23 +} from 'react-reconciler/src/ReactFiberReconciler';
packages/react-dom/testing.js
+13 -1
@@ -8,4 +8,16 @@
8 */
9
10 export * from './index.js';
11 -export {act} from 'react-reconciler/src/ReactFiberReconciler';
11 +export {
12 + act,
13 + createComponentSelector,
14 + createHasPsuedoClassSelector,
15 + createRoleSelector,
16 + createTestNameSelector,
17 + createTextSelector,
18 + getFindAllNodesFailureDescription,
19 + findAllNodes,
20 + findBoundingRects,
21 + focusWithin,
22 + observeVisibleRects,
23 +} from 'react-reconciler/src/ReactFiberReconciler';
packages/react-dom/testing.modern.fb.js
+13 -1
@@ -8,4 +8,16 @@
8 */
9
10 export * from './index.modern.fb.js';
11 -export {act} from 'react-reconciler/src/ReactFiberReconciler';
11 +export {
12 + act,
13 + createComponentSelector,
14 + createHasPsuedoClassSelector,
15 + createRoleSelector,
16 + createTestNameSelector,
17 + createTextSelector,
18 + getFindAllNodesFailureDescription,
19 + findAllNodes,
20 + findBoundingRects,
21 + focusWithin,
22 + observeVisibleRects,
23 +} from 'react-reconciler/src/ReactFiberReconciler';
packages/react-native-renderer/src/ReactFabricHostConfig.js
+1
@@ -184,6 +184,7 @@ class ReactFabricHostComponent {
184 export * from 'react-reconciler/src/ReactFiberHostConfigWithNoMutation';
185 export * from 'react-reconciler/src/ReactFiberHostConfigWithNoHydration';
186 export * from 'react-reconciler/src/ReactFiberHostConfigWithNoScopes';
187 +export * from 'react-reconciler/src/ReactFiberHostConfigWithNoTestSelectors';
188
189 export function appendInitialChild(
190 parentInstance: Instance,
packages/react-native-renderer/src/ReactNativeHostConfig.js
+1
@@ -88,6 +88,7 @@ function recursivelyUncacheFiberNode(node: Instance | TextInstance) {
88 export * from 'react-reconciler/src/ReactFiberHostConfigWithNoPersistence';
89 export * from 'react-reconciler/src/ReactFiberHostConfigWithNoHydration';
90 export * from 'react-reconciler/src/ReactFiberHostConfigWithNoScopes';
91 +export * from 'react-reconciler/src/ReactFiberHostConfigWithNoTestSelectors';
92
93 export function appendInitialChild(
94 parentInstance: Instance,
packages/react-reconciler/src/ReactFiberHostConfigWithNoTestSelectors.js new
+32
@@ -0,0 +1,32 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import invariant from 'shared/invariant';
11 +
12 +// Renderers that don't support test selectors
13 +// can re-export everything from this module.
14 +
15 +function shim(...args: any) {
16 + invariant(
17 + false,
18 + 'The current renderer does not support test selectors. ' +
19 + 'This error is likely caused by a bug in React. ' +
20 + 'Please file an issue.',
21 + );
22 +}
23 +
24 +// Test selectors (when unsupported)
25 +export const supportsTestSelectors = false;
26 +export const findFiberRoot = shim;
27 +export const getBoundingRect = shim;
28 +export const getTextContent = shim;
29 +export const isHiddenSubtree = shim;
30 +export const matchAccessibilityRole = shim;
31 +export const setFocusIfFocusable = shim;
32 +export const setupIntersectionObserver = shim;
packages/react-reconciler/src/ReactFiberReconciler.js
+50
@@ -39,6 +39,16 @@ import {
39 injectIntoDevTools as injectIntoDevTools_old,
40 act as act_old,
41 createPortal as createPortal_old,
42 + createComponentSelector as createComponentSelector_old,
43 + createHasPsuedoClassSelector as createHasPsuedoClassSelector_old,
44 + createRoleSelector as createRoleSelector_old,
45 + createTestNameSelector as createTestNameSelector_old,
46 + createTextSelector as createTextSelector_old,
47 + getFindAllNodesFailureDescription as getFindAllNodesFailureDescription_old,
48 + findAllNodes as findAllNodes_old,
49 + findBoundingRects as findBoundingRects_old,
50 + focusWithin as focusWithin_old,
51 + observeVisibleRects as observeVisibleRects_old,
52 } from './ReactFiberReconciler.old';
53
54 import {
@@ -66,6 +76,16 @@ import {
76 injectIntoDevTools as injectIntoDevTools_new,
77 act as act_new,
78 createPortal as createPortal_new,
79 + createComponentSelector as createComponentSelector_new,
80 + createHasPsuedoClassSelector as createHasPsuedoClassSelector_new,
81 + createRoleSelector as createRoleSelector_new,
82 + createTestNameSelector as createTestNameSelector_new,
83 + createTextSelector as createTextSelector_new,
84 + getFindAllNodesFailureDescription as getFindAllNodesFailureDescription_new,
85 + findAllNodes as findAllNodes_new,
86 + findBoundingRects as findBoundingRects_new,
87 + focusWithin as focusWithin_new,
88 + observeVisibleRects as observeVisibleRects_new,
89 } from './ReactFiberReconciler.new';
90
91 export const createContainer = enableNewReconciler
@@ -136,3 +156,33 @@ export const act = enableNewReconciler ? act_new : act_old;
156 export const createPortal = enableNewReconciler
157 ? createPortal_new
158 : createPortal_old;
159 +export const createComponentSelector = enableNewReconciler
160 + ? createComponentSelector_new
161 + : createComponentSelector_old;
162 +export const createHasPsuedoClassSelector = enableNewReconciler
163 + ? createHasPsuedoClassSelector_new
164 + : createHasPsuedoClassSelector_old;
165 +export const createRoleSelector = enableNewReconciler
166 + ? createRoleSelector_new
167 + : createRoleSelector_old;
168 +export const createTextSelector = enableNewReconciler
169 + ? createTextSelector_new
170 + : createTextSelector_old;
171 +export const createTestNameSelector = enableNewReconciler
172 + ? createTestNameSelector_new
173 + : createTestNameSelector_old;
174 +export const getFindAllNodesFailureDescription = enableNewReconciler
175 + ? getFindAllNodesFailureDescription_new
176 + : getFindAllNodesFailureDescription_old;
177 +export const findAllNodes = enableNewReconciler
178 + ? findAllNodes_new
179 + : findAllNodes_old;
180 +export const findBoundingRects = enableNewReconciler
181 + ? findBoundingRects_new
182 + : findBoundingRects_old;
183 +export const focusWithin = enableNewReconciler
184 + ? focusWithin_new
185 + : focusWithin_old;
186 +export const observeVisibleRects = enableNewReconciler
187 + ? observeVisibleRects_new
188 + : observeVisibleRects_old;
packages/react-reconciler/src/ReactFiberReconciler.new.js
+12
@@ -88,6 +88,18 @@ import {
88 } from './ReactFiberHotReloading.new';
89
90 export {createPortal} from './ReactPortal';
91 +export {
92 + createComponentSelector,
93 + createHasPsuedoClassSelector,
94 + createRoleSelector,
95 + createTestNameSelector,
96 + createTextSelector,
97 + getFindAllNodesFailureDescription,
98 + findAllNodes,
99 + findBoundingRects,
100 + focusWithin,
101 + observeVisibleRects,
102 +} from './ReactTestSelectors';
103
104 type OpaqueRoot = FiberRoot;
105
packages/react-reconciler/src/ReactFiberReconciler.old.js
+12
@@ -86,6 +86,18 @@ import {
86 } from './ReactFiberHotReloading.old';
87
88 export {createPortal} from './ReactPortal';
89 +export {
90 + createComponentSelector,
91 + createHasPsuedoClassSelector,
92 + createRoleSelector,
93 + createTestNameSelector,
94 + createTextSelector,
95 + getFindAllNodesFailureDescription,
96 + findAllNodes,
97 + findBoundingRects,
98 + focusWithin,
99 + observeVisibleRects,
100 +} from './ReactTestSelectors';
101
102 type OpaqueRoot = FiberRoot;
103
packages/react-reconciler/src/ReactFiberWorkLoop.new.js
+7 -2
@@ -192,7 +192,8 @@ import {
192 hasCaughtError,
193 clearCaughtError,
194 } from 'shared/ReactErrorUtils';
195 -import {onCommitRoot} from './ReactFiberDevToolsHook.new';
195 +import {onCommitRoot as onCommitRootDevTools} from './ReactFiberDevToolsHook.new';
196 +import {onCommitRoot as onCommitRootTestSelector} from './ReactTestSelectors';
197
198 // Used by `act`
199 import enqueueTask from 'shared/enqueueTask';
@@ -1995,7 +1996,11 @@ function commitRootImpl(root, renderPriorityLevel) {
1996 nestedUpdateCount = 0;
1997 }
1998
1998 - onCommitRoot(finishedWork.stateNode, renderPriorityLevel);
1999 + onCommitRootDevTools(finishedWork.stateNode, renderPriorityLevel);
2000 +
2001 + if (__DEV__) {
2002 + onCommitRootTestSelector();
2003 + }
2004
2005 // Always call this before exiting `commitRoot`, to ensure that any
2006 // additional work on this root is scheduled.
packages/react-reconciler/src/ReactFiberWorkLoop.old.js
+7 -2
@@ -189,7 +189,8 @@ import {
189 hasCaughtError,
190 clearCaughtError,
191 } from 'shared/ReactErrorUtils';
192 -import {onCommitRoot} from './ReactFiberDevToolsHook.old';
192 +import {onCommitRoot as onCommitRootDevTools} from './ReactFiberDevToolsHook.old';
193 +import {onCommitRoot as onCommitRootTestSelector} from './ReactTestSelectors';
194
195 // Used by `act`
196 import enqueueTask from 'shared/enqueueTask';
@@ -2119,7 +2120,11 @@ function commitRootImpl(root, renderPriorityLevel) {
2120 nestedUpdateCount = 0;
2121 }
2122
2122 - onCommitRoot(finishedWork.stateNode, expirationTime);
2123 + onCommitRootDevTools(finishedWork.stateNode, expirationTime);
2124 +
2125 + if (__DEV__) {
2126 + onCommitRootTestSelector();
2127 + }
2128
2129 // Always call this before exiting `commitRoot`, to ensure that any
2130 // additional work on this root is scheduled.
packages/react-reconciler/src/ReactTestSelectors.js new
+561
@@ -0,0 +1,561 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import type {Fiber} from 'react-reconciler/src/ReactInternalTypes';
11 +import type {Instance} from './ReactFiberHostConfig';
12 +
13 +import invariant from 'shared/invariant';
14 +import {HostComponent, HostText} from 'react-reconciler/src/ReactWorkTags';
15 +import getComponentName from 'shared/getComponentName';
16 +import {
17 + findFiberRoot,
18 + getBoundingRect,
19 + getInstanceFromNode,
20 + getTextContent,
21 + isHiddenSubtree,
22 + matchAccessibilityRole,
23 + setFocusIfFocusable,
24 + setupIntersectionObserver,
25 + supportsTestSelectors,
26 +} from './ReactFiberHostConfig';
27 +
28 +let COMPONENT_TYPE = 0b000;
29 +let HAS_PSEUDO_CLASS_TYPE = 0b001;
30 +let ROLE_TYPE = 0b010;
31 +let TEST_NAME_TYPE = 0b011;
32 +let TEXT_TYPE = 0b100;
33 +
34 +if (typeof Symbol === 'function' && Symbol.for) {
35 + const symbolFor = Symbol.for;
36 + COMPONENT_TYPE = symbolFor('selector.component');
37 + HAS_PSEUDO_CLASS_TYPE = symbolFor('selector.has_pseudo_class');
38 + ROLE_TYPE = symbolFor('selector.role');
39 + TEST_NAME_TYPE = symbolFor('selector.test_id');
40 + TEXT_TYPE = symbolFor('selector.text');
41 +}
42 +
43 +type Type = Symbol | number;
44 +
45 +type ComponentSelector = {|
46 + $$typeof: Type,
47 + value: React$AbstractComponent<empty, mixed>,
48 +|};
49 +
50 +type HasPsuedoClassSelector = {|
51 + $$typeof: Type,
52 + value: Array<Selector>,
53 +|};
54 +
55 +type RoleSelector = {|
56 + $$typeof: Type,
57 + value: string,
58 +|};
59 +
60 +type TextSelector = {|
61 + $$typeof: Type,
62 + value: string,
63 +|};
64 +
65 +type TestNameSelector = {|
66 + $$typeof: Type,
67 + value: string,
68 +|};
69 +
70 +type Selector =
71 + | ComponentSelector
72 + | HasPsuedoClassSelector
73 + | RoleSelector
74 + | TextSelector
75 + | TestNameSelector;
76 +
77 +export function createComponentSelector(
78 + component: React$AbstractComponent<empty, mixed>,
79 +): ComponentSelector {
80 + return {
81 + $$typeof: COMPONENT_TYPE,
82 + value: component,
83 + };
84 +}
85 +
86 +export function createHasPsuedoClassSelector(
87 + selectors: Array<Selector>,
88 +): HasPsuedoClassSelector {
89 + return {
90 + $$typeof: HAS_PSEUDO_CLASS_TYPE,
91 + value: selectors,
92 + };
93 +}
94 +
95 +export function createRoleSelector(role: string): RoleSelector {
96 + return {
97 + $$typeof: ROLE_TYPE,
98 + value: role,
99 + };
100 +}
101 +
102 +export function createTextSelector(text: string): TextSelector {
103 + return {
104 + $$typeof: TEXT_TYPE,
105 + value: text,
106 + };
107 +}
108 +
109 +export function createTestNameSelector(id: string): TestNameSelector {
110 + return {
111 + $$typeof: TEST_NAME_TYPE,
112 + value: id,
113 + };
114 +}
115 +
116 +function findFiberRootForHostRoot(hostRoot: Instance): Fiber {
117 + const maybeFiber = getInstanceFromNode((hostRoot: any));
118 + if (maybeFiber != null) {
119 + invariant(
120 + typeof maybeFiber.memoizedProps['data-testname'] === 'string',
121 + 'Invalid host root specified. Should be either a React container or a node with a testname attribute.',
122 + );
123 + return ((maybeFiber: any): Fiber);
124 + } else {
125 + const fiberRoot = findFiberRoot(hostRoot);
126 + invariant(
127 + fiberRoot !== null,
128 + 'Could not find React container within specified host subtree.',
129 + );
130 + // The Flow type for FiberRoot is a little funky.
131 + // createFiberRoot() cheats this by treating the root as :any and adding stateNode lazily.
132 + return ((fiberRoot: any).stateNode.current: Fiber);
133 + }
134 +}
135 +
136 +function matchSelector(fiber: Fiber, selector: Selector): boolean {
137 + switch (selector.$$typeof) {
138 + case COMPONENT_TYPE:
139 + if (fiber.type === selector.value) {
140 + return true;
141 + }
142 + break;
143 + case HAS_PSEUDO_CLASS_TYPE:
144 + return hasMatchingPaths(
145 + fiber,
146 + ((selector: any): HasPsuedoClassSelector).value,
147 + );
148 + case ROLE_TYPE:
149 + if (fiber.tag === HostComponent) {
150 + const node = fiber.stateNode;
151 + if (
152 + matchAccessibilityRole(node, ((selector: any): RoleSelector).value)
153 + ) {
154 + return true;
155 + }
156 + }
157 + break;
158 + case TEXT_TYPE:
159 + if (fiber.tag === HostComponent || fiber.tag === HostText) {
160 + const textContent = getTextContent(fiber);
161 + if (
162 + textContent !== null &&
163 + textContent.indexOf(((selector: any): TextSelector).value) >= 0
164 + ) {
165 + return true;
166 + }
167 + }
168 + break;
169 + case TEST_NAME_TYPE:
170 + if (fiber.tag === HostComponent) {
171 + const dataTestID = fiber.memoizedProps['data-testname'];
172 + if (
173 + typeof dataTestID === 'string' &&
174 + dataTestID.toLowerCase() ===
175 + ((selector: any): TestNameSelector).value.toLowerCase()
176 + ) {
177 + return true;
178 + }
179 + }
180 + break;
181 + default:
182 + invariant(null, 'Invalid selector type %s specified.', selector);
183 + break;
184 + }
185 +
186 + return false;
187 +}
188 +
189 +function selectorToString(selector: Selector): string | null {
190 + switch (selector.$$typeof) {
191 + case COMPONENT_TYPE:
192 + const displayName = getComponentName(selector.value) || 'Unknown';
193 + return `<${displayName}>`;
194 + case HAS_PSEUDO_CLASS_TYPE:
195 + return `:has(${selectorToString(selector) || ''})`;
196 + case ROLE_TYPE:
197 + return `[role="${((selector: any): RoleSelector).value}"]`;
198 + case TEXT_TYPE:
199 + return `"${((selector: any): TextSelector).value}"`;
200 + case TEST_NAME_TYPE:
201 + return `[data-testname="${((selector: any): TestNameSelector).value}"]`;
202 + default:
203 + invariant(null, 'Invalid selector type %s specified.', selector);
204 + break;
205 + }
206 +
207 + return null;
208 +}
209 +
210 +function findPaths(root: Fiber, selectors: Array<Selector>): Array<Fiber> {
211 + const matchingFibers: Array<Fiber> = [];
212 +
213 + const stack = [root, 0];
214 + let index = 0;
215 + while (index < stack.length) {
216 + const fiber = ((stack[index++]: any): Fiber);
217 + let selectorIndex = ((stack[index++]: any): number);
218 + let selector = selectors[selectorIndex];
219 +
220 + if (fiber.tag === HostComponent && isHiddenSubtree(fiber)) {
221 + continue;
222 + } else {
223 + while (selector != null && matchSelector(fiber, selector)) {
224 + selectorIndex++;
225 + selector = selectors[selectorIndex];
226 + }
227 + }
228 +
229 + if (selectorIndex === selectors.length) {
230 + matchingFibers.push(fiber);
231 + } else {
232 + let child = fiber.child;
233 + while (child !== null) {
234 + stack.push(child, selectorIndex);
235 + child = child.sibling;
236 + }
237 + }
238 + }
239 +
240 + return matchingFibers;
241 +}
242 +
243 +// Same as findPaths but with eager bailout on first match
244 +function hasMatchingPaths(root: Fiber, selectors: Array<Selector>): boolean {
245 + const stack = [root, 0];
246 + let index = 0;
247 + while (index < stack.length) {
248 + const fiber = ((stack[index++]: any): Fiber);
249 + let selectorIndex = ((stack[index++]: any): number);
250 + let selector = selectors[selectorIndex];
251 +
252 + if (fiber.tag === HostComponent && isHiddenSubtree(fiber)) {
253 + continue;
254 + } else {
255 + while (selector != null && matchSelector(fiber, selector)) {
256 + selectorIndex++;
257 + selector = selectors[selectorIndex];
258 + }
259 + }
260 +
261 + if (selectorIndex === selectors.length) {
262 + return true;
263 + } else {
264 + let child = fiber.child;
265 + while (child !== null) {
266 + stack.push(child, selectorIndex);
267 + child = child.sibling;
268 + }
269 + }
270 + }
271 +
272 + return false;
273 +}
274 +
275 +export function findAllNodes(
276 + hostRoot: Instance,
277 + selectors: Array<Selector>,
278 +): Array<Instance> {
279 + if (!supportsTestSelectors) {
280 + invariant(false, 'Test selector API is not supported by this renderer.');
281 + }
282 +
283 + const root = findFiberRootForHostRoot(hostRoot);
284 + const matchingFibers = findPaths(root, selectors);
285 +
286 + const instanceRoots: Array<Instance> = [];
287 +
288 + const stack = Array.from(matchingFibers);
289 + let index = 0;
290 + while (index < stack.length) {
291 + const node = ((stack[index++]: any): Fiber);
292 + if (node.tag === HostComponent) {
293 + if (isHiddenSubtree(node)) {
294 + continue;
295 + }
296 + instanceRoots.push(node.stateNode);
297 + } else {
298 + let child = node.child;
299 + while (child !== null) {
300 + stack.push(child);
301 + child = child.sibling;
302 + }
303 + }
304 + }
305 +
306 + return instanceRoots;
307 +}
308 +
309 +export function getFindAllNodesFailureDescription(
310 + hostRoot: Instance,
311 + selectors: Array<Selector>,
312 +): string | null {
313 + if (!supportsTestSelectors) {
314 + invariant(false, 'Test selector API is not supported by this renderer.');
315 + }
316 +
317 + const root = findFiberRootForHostRoot(hostRoot);
318 +
319 + let maxSelectorIndex: number = 0;
320 + const matchedNames = [];
321 +
322 + // The logic of this loop should be kept in sync with findPaths()
323 + const stack = [root, 0];
324 + let index = 0;
325 + while (index < stack.length) {
326 + const fiber = ((stack[index++]: any): Fiber);
327 + let selectorIndex = ((stack[index++]: any): number);
328 + const selector = selectors[selectorIndex];
329 +
330 + if (fiber.tag === HostComponent && isHiddenSubtree(fiber)) {
331 + continue;
332 + } else if (matchSelector(fiber, selector)) {
333 + matchedNames.push(selectorToString(selector));
334 + selectorIndex++;
335 +
336 + if (selectorIndex > maxSelectorIndex) {
337 + maxSelectorIndex = selectorIndex;
338 + }
339 + }
340 +
341 + if (selectorIndex < selectors.length) {
342 + let child = fiber.child;
343 + while (child !== null) {
344 + stack.push(child, selectorIndex);
345 + child = child.sibling;
346 + }
347 + }
348 + }
349 +
350 + if (maxSelectorIndex < selectors.length) {
351 + const unmatchedNames = [];
352 + for (let i = maxSelectorIndex; i < selectors.length; i++) {
353 + unmatchedNames.push(selectorToString(selectors[i]));
354 + }
355 +
356 + return (
357 + 'findAllNodes was able to match part of the selector:\n' +
358 + ` ${matchedNames.join(' > ')}\n\n` +
359 + 'No matching component was found for:\n' +
360 + ` ${unmatchedNames.join(' > ')}`
361 + );
362 + }
363 +
364 + return null;
365 +}
366 +
367 +export type BoundingRect = {|
368 + x: number,
369 + y: number,
370 + width: number,
371 + height: number,
372 +|};
373 +
374 +export function findBoundingRects(
375 + hostRoot: Instance,
376 + selectors: Array<Selector>,
377 +): Array<BoundingRect> {
378 + if (!supportsTestSelectors) {
379 + invariant(false, 'Test selector API is not supported by this renderer.');
380 + }
381 +
382 + const instanceRoots = findAllNodes(hostRoot, selectors);
383 +
384 + const boundingRects: Array<BoundingRect> = [];
385 + for (let i = 0; i < instanceRoots.length; i++) {
386 + boundingRects.push(getBoundingRect(instanceRoots[i]));
387 + }
388 +
389 + for (let i = boundingRects.length - 1; i > 0; i--) {
390 + const targetRect = boundingRects[i];
391 + const targetLeft = targetRect.x;
392 + const targetRight = targetLeft + targetRect.width;
393 + const targetTop = targetRect.y;
394 + const targetBottom = targetTop + targetRect.height;
395 +
396 + for (let j = i - 1; j >= 0; j--) {
397 + if (i !== j) {
398 + const otherRect = boundingRects[j];
399 + const otherLeft = otherRect.x;
400 + const otherRight = otherLeft + otherRect.width;
401 + const otherTop = otherRect.y;
402 + const otherBottom = otherTop + otherRect.height;
403 +
404 + // Merging all rects to the minimums set would be complicated,
405 + // but we can handle the most common cases:
406 + // 1. completely overlapping rects
407 + // 2. adjacent rects that are the same width or height (e.g. items in a list)
408 + //
409 + // Even given the above constraints,
410 + // we still won't end up with the fewest possible rects without doing multiple passes,
411 + // but it's good enough for this purpose.
412 +
413 + if (
414 + targetLeft >= otherLeft &&
415 + targetTop >= otherTop &&
416 + targetRight <= otherRight &&
417 + targetBottom <= otherBottom
418 + ) {
419 + // Complete overlapping rects; remove the inner one.
420 + boundingRects.splice(i, 1);
421 + break;
422 + } else if (
423 + targetLeft === otherLeft &&
424 + targetRect.width === otherRect.width &&
425 + !(otherBottom < targetTop) &&
426 + !(otherTop > targetBottom)
427 + ) {
428 + // Adjacent vertical rects; merge them.
429 + if (otherTop > targetTop) {
430 + otherRect.height += otherTop - targetTop;
431 + otherRect.y = targetTop;
432 + }
433 + if (otherBottom < targetBottom) {
434 + otherRect.height = targetBottom - otherTop;
435 + }
436 +
437 + boundingRects.splice(i, 1);
438 + break;
439 + } else if (
440 + targetTop === otherTop &&
441 + targetRect.height === otherRect.height &&
442 + !(otherRight < targetLeft) &&
443 + !(otherLeft > targetRight)
444 + ) {
445 + // Adjacent horizontal rects; merge them.
446 + if (otherLeft > targetLeft) {
447 + otherRect.width += otherLeft - targetLeft;
448 + otherRect.x = targetLeft;
449 + }
450 + if (otherRight < targetRight) {
451 + otherRect.width = targetRight - otherLeft;
452 + }
453 +
454 + boundingRects.splice(i, 1);
455 + break;
456 + }
457 + }
458 + }
459 + }
460 +
461 + return boundingRects;
462 +}
463 +
464 +export function focusWithin(
465 + hostRoot: Instance,
466 + selectors: Array<Selector>,
467 +): boolean {
468 + if (!supportsTestSelectors) {
469 + invariant(false, 'Test selector API is not supported by this renderer.');
470 + }
471 +
472 + const root = findFiberRootForHostRoot(hostRoot);
473 + const matchingFibers = findPaths(root, selectors);
474 +
475 + const stack = Array.from(matchingFibers);
476 + let index = 0;
477 + while (index < stack.length) {
478 + const fiber = ((stack[index++]: any): Fiber);
479 + if (isHiddenSubtree(fiber)) {
480 + continue;
481 + }
482 + if (fiber.tag === HostComponent) {
483 + const node = fiber.stateNode;
484 + if (setFocusIfFocusable(node)) {
485 + return true;
486 + }
487 + }
488 + let child = fiber.child;
489 + while (child !== null) {
490 + stack.push(child);
491 + child = child.sibling;
492 + }
493 + }
494 +
495 + return false;
496 +}
497 +
498 +const commitHooks: Array<Function> = [];
499 +
500 +export function onCommitRoot(): void {
501 + if (supportsTestSelectors) {
502 + commitHooks.forEach(commitHook => commitHook());
503 + }
504 +}
505 +
506 +export type IntersectionObserverOptions = Object;
507 +
508 +export type ObserveVisibleRectsCallback = (
509 + intersections: Array<{ratio: number, rect: BoundingRect}>,
510 +) => void;
511 +
512 +export function observeVisibleRects(
513 + hostRoot: Instance,
514 + selectors: Array<Selector>,
515 + callback: (intersections: Array<{ratio: number, rect: BoundingRect}>) => void,
516 + options?: IntersectionObserverOptions,
517 +): {|disconnect: () => void|} {
518 + if (!supportsTestSelectors) {
519 + invariant(false, 'Test selector API is not supported by this renderer.');
520 + }
521 +
522 + const instanceRoots = findAllNodes(hostRoot, selectors);
523 +
524 + const {disconnect, observe, unobserve} = setupIntersectionObserver(
525 + instanceRoots,
526 + callback,
527 + options,
528 + );
529 +
530 + // When React mutates the host environment, we may need to change what we're listening to.
531 + const commitHook = () => {
532 + const nextInstanceRoots = findAllNodes(hostRoot, selectors);
533 +
534 + instanceRoots.forEach(target => {
535 + if (nextInstanceRoots.indexOf(target) < 0) {
536 + unobserve(target);
537 + }
538 + });
539 +
540 + nextInstanceRoots.forEach(target => {
541 + if (instanceRoots.indexOf(target) < 0) {
542 + observe(target);
543 + }
544 + });
545 + };
546 +
547 + commitHooks.push(commitHook);
548 +
549 + return {
550 + disconnect: () => {
551 + // Stop listening for React mutations:
552 + const index = commitHooks.indexOf(commitHook);
553 + if (index >= 0) {
554 + commitHooks.splice(index, 1);
555 + }
556 +
557 + // Disconnect the host observer:
558 + disconnect();
559 + },
560 + };
561 +}
packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js
+14
@@ -88,6 +88,20 @@ export const prepareScopeUpdate = $$$hostConfig.preparePortalMount;
88 export const prepareScopeUnmount = $$$hostConfig.prepareScopeUnmount;
89 export const getInstanceFromScope = $$$hostConfig.getInstanceFromScope;
90
91 +// -------------------
92 +// Test selectors
93 +// (optional)
94 +// -------------------
95 +export const supportsTestSelectors = $$$hostConfig.supportsTestSelectors;
96 +export const findFiberRoot = $$$hostConfig.findFiberRoot;
97 +export const getBoundingRect = $$$hostConfig.getBoundingRect;
98 +export const getTextContent = $$$hostConfig.getTextContent;
99 +export const isHiddenSubtree = $$$hostConfig.isHiddenSubtree;
100 +export const matchAccessibilityRole = $$$hostConfig.matchAccessibilityRole;
101 +export const setFocusIfFocusable = $$$hostConfig.setFocusIfFocusable;
102 +export const setupIntersectionObserver =
103 + $$$hostConfig.setupIntersectionObserver;
104 +
105 // -------------------
106 // Mutation
107 // (optional)
packages/react-test-renderer/src/ReactTestHostConfig.js
+1
@@ -56,6 +56,7 @@ export type RendererInspectionConfig = $ReadOnly<{||}>;
56
57 export * from 'react-reconciler/src/ReactFiberHostConfigWithNoPersistence';
58 export * from 'react-reconciler/src/ReactFiberHostConfigWithNoHydration';
59 +export * from 'react-reconciler/src/ReactFiberHostConfigWithNoTestSelectors';
60
61 const EVENT_COMPONENT_CONTEXT = {};
62 const NO_CONTEXT = {};
scripts/error-codes/codes.json
+7 -1
@@ -1,3 +1,4 @@
1 +
2 {
3 "0": "React.addons.createFragment(...): Encountered an invalid child; DOM elements are not valid children of React components.",
4 "1": "update(): expected target of %s to be an array; got %s.",
@@ -357,5 +358,10 @@
358 "357": "The current renderer does not support React Scopes. This error is likely caused by a bug in React. Please file an issue.",
359 "358": "Invalid update priority: %s. This is a bug in React.",
360 "359": "Invalid transition priority: %s. This is a bug in React.",
360 - "360": "Invalid lane: %s. This is a bug in React."
361 + "360": "Invalid lane: %s. This is a bug in React.",
362 + "361": "The current renderer does not support test selectors. This error is likely caused by a bug in React. Please file an issue.",
363 + "362": "Could not find React container within specified host subtree.",
364 + "363": "Test selector API is not supported by this renderer.",
365 + "364": "Invalid host root specified. Should be either a React container or a node with a testname attribute.",
366 + "365": "Invalid selector type %s specified."
367 }