main
js 732 lines 20.8 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and 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
8 'use strict';
9
10 const TOOL_NAMES = [
11 'react_get_component_tree',
12 'react_get_component_by_uid',
13 'react_get_component_by_dom_element',
14 'react_find_components',
15 'react_get_component_source',
16 'react_get_owner_stack_trace',
17 'react_get_parent_stack',
18 'react_get_owner_stack',
19 'react_start_profiling',
20 'react_stop_profiling',
21 'react_get_trace_overview',
22 'react_get_commit_report',
23 ];
24
25 let register;
26 let facade;
27 let toolGroup;
28 let unregister;
29 let React;
30 let ReactDOMClient;
31 let act;
32 let container;
33
34 // Look up a registered tool by name.
35 function getTool(name) {
36 return toolGroup.tools.find(tool => tool.name === name);
37 }
38
39 // Dispatch chrome-devtools-mcp's discovery event and return the tool group the
40 // page responds with (synchronously). The tools are built lazily in the
41 // handler, so discovery is what actually constructs them.
42 function discover() {
43 let group = null;
44 const event = new CustomEvent('devtoolstooldiscovery');
45 // $FlowFixMe[prop-missing] chrome-devtools-mcp attaches respondWith
46 event.respondWith = responded => {
47 group = responded;
48 };
49 window.dispatchEvent(event);
50 return group;
51 }
52
53 describe('react-devtools-cdt-mcp', () => {
54 beforeEach(() => {
55 jest.resetModules();
56 global.IS_REACT_ACT_ENVIRONMENT = true;
57
58 delete globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
59 delete globalThis.__dtmcp;
60
61 // register() installs the facade BEFORE React so the hook captures the
62 // first commit, then registers the discovery listener (tools are built
63 // lazily when chrome-devtools-mcp discovers them).
64 register = require('../DevToolsCdtMcp').register;
65 const result = register();
66 facade = result.facade;
67 unregister = result.unregister;
68
69 // Obtain the tool group the way chrome-devtools-mcp does.
70 toolGroup = discover();
71
72 React = require('react');
73 ReactDOMClient = require('react-dom/client');
74 act = React.act;
75
76 container = document.createElement('div');
77 document.body.appendChild(container);
78 });
79
80 afterEach(() => {
81 // Remove the discovery listener so it does not accumulate on the shared
82 // jsdom window across tests.
83 unregister();
84 document.body.removeChild(container);
85 container = null;
86 });
87
88 it('installs the DevTools hook on register', () => {
89 expect(globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__).toBe(facade.hook);
90 });
91
92 it('does not install any tool globals (chrome-devtools-mcp owns __dtmcp)', () => {
93 expect(globalThis.__REACT_TOOLS__).toBeUndefined();
94 expect(globalThis.__dtmcp).toBeUndefined();
95 });
96
97 it('root entry exports tools without registering', () => {
98 unregister();
99 delete globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
100 jest.resetModules();
101
102 const api = require('../index');
103
104 expect(typeof api.register).toBe('function');
105 expect(typeof api.buildToolGroup).toBe('function');
106 expect(globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__).toBeUndefined();
107 });
108
109 it('throws when the register entry is imported outside an event target', () => {
110 const originalAddEventListener = globalThis.addEventListener;
111 const originalRemoveEventListener = globalThis.removeEventListener;
112
113 unregister();
114 delete globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
115 jest.resetModules();
116
117 try {
118 // $FlowFixMe[cannot-write]
119 globalThis.addEventListener = undefined;
120 // $FlowFixMe[cannot-write]
121 globalThis.removeEventListener = undefined;
122
123 expect(() => require('../register')).toThrow(
124 'react-devtools-cdt-mcp/register must be imported in a browser-like environment',
125 );
126 expect(globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__).toBeUndefined();
127 } finally {
128 globalThis.addEventListener = originalAddEventListener;
129 globalThis.removeEventListener = originalRemoveEventListener;
130 }
131 });
132
133 it('register entry installs the DevTools hook', () => {
134 const originalAddEventListener = globalThis.addEventListener;
135 const originalRemoveEventListener = globalThis.removeEventListener;
136 let autoListener = null;
137
138 unregister();
139 delete globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
140 jest.resetModules();
141
142 try {
143 // $FlowFixMe[cannot-write]
144 globalThis.addEventListener = (type, listener, options) => {
145 if (type === 'devtoolstooldiscovery') {
146 autoListener = listener;
147 }
148 return originalAddEventListener.call(
149 globalThis,
150 type,
151 listener,
152 options,
153 );
154 };
155
156 require('../register');
157
158 expect(globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__).toBeDefined();
159 } finally {
160 if (autoListener !== null) {
161 originalRemoveEventListener.call(
162 globalThis,
163 'devtoolstooldiscovery',
164 autoListener,
165 );
166 }
167 globalThis.addEventListener = originalAddEventListener;
168 globalThis.removeEventListener = originalRemoveEventListener;
169 }
170 });
171
172 it('returns the cached registration for repeated calls per target', () => {
173 let listener = null;
174 const target = {
175 addEventListener: jest.fn((type, callback) => {
176 expect(type).toBe('devtoolstooldiscovery');
177 listener = callback;
178 }),
179 removeEventListener: jest.fn(),
180 };
181
182 const first = register(target);
183 const second = register(target);
184
185 expect(target.addEventListener).toHaveBeenCalledTimes(1);
186 expect(second).toBe(first);
187 expect(second.facade).toBe(first.facade);
188
189 let firstGroup = null;
190 let secondGroup = null;
191 listener({
192 respondWith: group => {
193 firstGroup = group;
194 },
195 });
196 listener({
197 respondWith: group => {
198 secondGroup = group;
199 },
200 });
201 expect(secondGroup).toBe(firstGroup);
202
203 first.unregister();
204 expect(target.removeEventListener).toHaveBeenCalledTimes(1);
205 expect(target.removeEventListener).toHaveBeenCalledWith(
206 'devtoolstooldiscovery',
207 listener,
208 );
209
210 second.unregister();
211 expect(target.removeEventListener).toHaveBeenCalledTimes(1);
212
213 const third = register(target);
214 expect(third).not.toBe(first);
215 expect(target.addEventListener).toHaveBeenCalledTimes(2);
216 third.unregister();
217 });
218
219 it('does not write registration state to the target', () => {
220 let listener = null;
221 const existingHook = {
222 inject: jest.fn(() => 0),
223 onCommitFiberRoot: jest.fn(),
224 onPostCommitFiberRoot: jest.fn(),
225 renderers: new Map(),
226 };
227 const target = Object.preventExtensions({
228 __REACT_DEVTOOLS_GLOBAL_HOOK__: existingHook,
229 addEventListener: jest.fn((type, callback) => {
230 expect(type).toBe('devtoolstooldiscovery');
231 listener = callback;
232 }),
233 removeEventListener: jest.fn(),
234 });
235
236 const first = register(target);
237 const second = register(target);
238
239 expect(target.addEventListener).toHaveBeenCalledTimes(1);
240 expect(second).toBe(first);
241 expect(second.facade).toBe(first.facade);
242 expect(Object.keys(target).sort()).toEqual([
243 '__REACT_DEVTOOLS_GLOBAL_HOOK__',
244 'addEventListener',
245 'removeEventListener',
246 ]);
247
248 first.unregister();
249 second.unregister();
250 expect(target.removeEventListener).toHaveBeenCalledWith(
251 'devtoolstooldiscovery',
252 listener,
253 );
254 });
255
256 it('builds a "react" tool group exposing every facade tool', () => {
257 expect(toolGroup.name).toBe('react');
258 expect(typeof toolGroup.description).toBe('string');
259 expect(toolGroup.description.length).toBeGreaterThan(0);
260 expect(toolGroup.tools.map(tool => tool.name)).toEqual(TOOL_NAMES);
261
262 toolGroup.tools.forEach(tool => {
263 expect(typeof tool.description).toBe('string');
264 expect(tool.description.length).toBeGreaterThan(0);
265 expect(tool.inputSchema.type).toBe('object');
266 expect(typeof tool.execute).toBe('function');
267 });
268 expect(getTool('react_get_parent_stack').description).toEqual(
269 expect.stringContaining('Rendered parent chain'),
270 );
271 expect(getTool('react_get_owner_stack').description).toEqual(
272 expect.stringContaining('Owners describe'),
273 );
274 });
275
276 it('declares JSON-Schema input with required params', () => {
277 expect(getTool('react_get_component_tree').inputSchema).toEqual({
278 type: 'object',
279 properties: {
280 depth: {type: 'number', description: expect.any(String)},
281 rootUid: {type: 'string', description: expect.any(String)},
282 },
283 });
284 expect(getTool('react_get_component_by_uid').inputSchema).toEqual({
285 type: 'object',
286 properties: {
287 uid: {type: 'string', description: expect.any(String)},
288 includeHooks: {type: 'boolean', description: expect.any(String)},
289 },
290 required: ['uid'],
291 });
292 expect(getTool('react_get_component_by_dom_element').inputSchema).toEqual({
293 type: 'object',
294 properties: {
295 element: {
296 type: 'object',
297 'x-mcp-type': 'HTMLElement',
298 description: expect.any(String),
299 },
300 },
301 required: ['element'],
302 });
303 expect(getTool('react_find_components').inputSchema).toEqual({
304 type: 'object',
305 properties: {
306 name: {type: 'string', description: expect.any(String)},
307 rootUid: {type: 'string', description: expect.any(String)},
308 page: {type: 'number', description: expect.any(String)},
309 pageSize: {type: 'number', description: expect.any(String)},
310 },
311 required: ['name'],
312 });
313 expect(getTool('react_get_parent_stack').inputSchema).toEqual({
314 type: 'object',
315 properties: {uid: {type: 'string', description: expect.any(String)}},
316 required: ['uid'],
317 });
318 expect(getTool('react_get_owner_stack').inputSchema).toEqual({
319 type: 'object',
320 properties: {uid: {type: 'string', description: expect.any(String)}},
321 required: ['uid'],
322 });
323 expect(getTool('react_start_profiling').inputSchema).toEqual({
324 type: 'object',
325 properties: {
326 traceName: {type: 'string', description: expect.any(String)},
327 },
328 });
329 expect(getTool('react_get_commit_report').inputSchema).toEqual({
330 type: 'object',
331 properties: {
332 traceName: {type: 'string', description: expect.any(String)},
333 commitIndex: {type: 'number', description: expect.any(String)},
334 },
335 required: ['traceName', 'commitIndex'],
336 });
337 expect(getTool('react_stop_profiling').inputSchema).toEqual({
338 type: 'object',
339 properties: {},
340 });
341 });
342
343 it('react_get_component_tree returns the component tree', () => {
344 function App() {
345 return <div>hello</div>;
346 }
347
348 act(() => {
349 ReactDOMClient.createRoot(container).render(<App />);
350 });
351
352 const result = getTool('react_get_component_tree').execute({});
353 // Uids are assigned deterministically as nodes are first encountered. A
354 // node's first child is assigned before the node itself, so App (the host
355 // root's first child) is r0, the host root is r1, and the div is r2.
356 expect(result).toEqual({
357 nodes: [
358 {
359 uid: 'r1',
360 type: 'root',
361 name: 'createRoot()',
362 key: null,
363 firstChild: 'r0',
364 nextSibling: null,
365 },
366 {
367 uid: 'r0',
368 type: 'function',
369 name: 'App',
370 key: null,
371 firstChild: 'r2',
372 nextSibling: null,
373 },
374 {
375 uid: 'r2',
376 type: 'host',
377 name: 'div',
378 key: null,
379 firstChild: null,
380 nextSibling: null,
381 },
382 ],
383 });
384 });
385
386 it('react_find_components maps args and returns paginated results', () => {
387 function Card() {
388 return <div>card</div>;
389 }
390 function App() {
391 return (
392 <div>
393 <Card key="a" />
394 <Card key="b" />
395 </div>
396 );
397 }
398
399 act(() => {
400 ReactDOMClient.createRoot(container).render(<App />);
401 });
402
403 const result = getTool('react_find_components').execute({name: 'Card'});
404 // Matches are assigned in result order during row building: Card "a" is r0
405 // (its div r1) and Card "b" is r2 (its div r3).
406 expect(result).toEqual({
407 page: 1,
408 pageSize: 10,
409 totalCount: 2,
410 totalPages: 1,
411 results: [
412 {
413 uid: 'r0',
414 type: 'function',
415 name: 'Card',
416 key: 'a',
417 firstChild: 'r1',
418 nextSibling: null,
419 },
420 {
421 uid: 'r2',
422 type: 'function',
423 name: 'Card',
424 key: 'b',
425 firstChild: 'r3',
426 nextSibling: null,
427 },
428 ],
429 });
430 });
431
432 it('react_get_component_by_uid returns props and hooks when requested', () => {
433 function Counter() {
434 const [count] = React.useState(3);
435 return <div>{count}</div>;
436 }
437
438 act(() => {
439 ReactDOMClient.createRoot(container).render(<Counter title="hi" />);
440 });
441
442 const result = getTool('react_get_component_tree').execute({});
443 const tree = result.nodes;
444 // Counter is the host root's first child, so it is r0 (host root r1, the
445 // div r2).
446 expect(tree).toEqual([
447 {
448 uid: 'r1',
449 type: 'root',
450 name: 'createRoot()',
451 key: null,
452 firstChild: 'r0',
453 nextSibling: null,
454 },
455 {
456 uid: 'r0',
457 type: 'function',
458 name: 'Counter',
459 key: null,
460 firstChild: 'r2',
461 nextSibling: null,
462 },
463 {
464 uid: 'r2',
465 type: 'host',
466 name: 'div',
467 key: null,
468 firstChild: null,
469 nextSibling: null,
470 },
471 ]);
472
473 const counter = tree.find(n => n.name === 'Counter');
474 expect(counter.uid).toBe('r0');
475
476 const info = getTool('react_get_component_by_uid').execute({
477 uid: counter.uid,
478 includeHooks: true,
479 });
480 expect(info).toEqual({
481 uid: 'r0',
482 type: 'function',
483 name: 'Counter',
484 props: {title: 'hi'},
485 hooks: [{id: 0, name: 'State', value: 3, subHooks: []}],
486 });
487
488 const infoWithoutHooks = getTool('react_get_component_by_uid').execute({
489 uid: counter.uid,
490 });
491 expect(infoWithoutHooks).toEqual({
492 uid: 'r0',
493 type: 'function',
494 name: 'Counter',
495 props: {title: 'hi'},
496 });
497 });
498
499 it('react_get_parent_stack returns structural ancestors', () => {
500 function Child() {
501 return <span>leaf</span>;
502 }
503 function Owner() {
504 return (
505 <section>
506 <Child />
507 </section>
508 );
509 }
510
511 act(() => {
512 ReactDOMClient.createRoot(container).render(<Owner />);
513 });
514
515 const tree = getTool('react_get_component_tree').execute({}).nodes;
516 const child = tree.find(n => n.name === 'Child');
517
518 expect(getTool('react_get_parent_stack').execute({uid: child.uid})).toEqual(
519 [
520 {uid: 'r2', name: 'section', type: 'host'},
521 {uid: 'r0', name: 'Owner', type: 'function'},
522 {uid: 'r1', name: expect.any(String), type: 'root'},
523 ],
524 );
525 });
526
527 it('react_get_component_by_dom_element returns the DOM element component', () => {
528 function Wrapper({children}) {
529 return <section className="wrap">{children}</section>;
530 }
531 function App() {
532 return (
533 <Wrapper>
534 <button className="action">Run</button>
535 </Wrapper>
536 );
537 }
538
539 act(() => {
540 ReactDOMClient.createRoot(container).render(<App />);
541 });
542
543 const button = container.querySelector('button.action');
544 const tree = getTool('react_get_component_tree').execute({}).nodes;
545 const host = tree.find(n => n.name === 'button');
546 const wrapper = tree.find(n => n.name === 'Wrapper');
547 const result = getTool('react_get_component_by_dom_element').execute({
548 element: button,
549 });
550
551 expect(result.uid).toBe(host.uid);
552 expect(result.uid).not.toBe(wrapper.uid);
553 expect(result).toMatchObject({
554 type: 'host',
555 name: 'button',
556 props: {className: 'action'},
557 });
558 });
559
560 it('react_get_component_by_dom_element returns DOM-oriented errors', () => {
561 expect(getTool('react_get_component_by_dom_element').execute({})).toEqual({
562 error: 'DOM element is required',
563 });
564
565 act(() => {
566 ReactDOMClient.createRoot(container).render(<div className="host" />);
567 });
568
569 const unmanaged = document.createElement('span');
570 expect(
571 getTool('react_get_component_by_dom_element').execute({
572 element: unmanaged,
573 }),
574 ).toEqual({error: 'DOM element is not managed by React'});
575 });
576
577 it('returns tool errors as a raw payload', () => {
578 const result = getTool('react_get_component_by_uid').execute({
579 uid: 'r9999',
580 });
581 expect(result).toEqual({error: 'Component not found: "r9999"'});
582 });
583
584 it('serializes Error tool payloads with causes', () => {
585 const {buildToolGroup} = require('../DevToolsCdtMcp');
586 const group = buildToolGroup({
587 getComponentByUid: () => ({
588 error: new Error('Failed to inspect hooks.', {
589 cause: new Error('Cannot inspect hooks'),
590 }),
591 }),
592 });
593 const tool = group.tools.find(
594 item => item.name === 'react_get_component_by_uid',
595 );
596
597 expect(tool.execute({uid: 'r0', includeHooks: true})).toEqual({
598 error: 'Failed to inspect hooks. Cause: Cannot inspect hooks',
599 });
600 });
601
602 it('profiling tools record and report commits through the integration', () => {
603 function Counter({count}) {
604 return <div>{'Count: ' + count}</div>;
605 }
606
607 const root = ReactDOMClient.createRoot(container);
608 act(() => {
609 root.render(<Counter count={0} />);
610 });
611
612 expect(
613 getTool('react_start_profiling').execute({traceName: 'trace'}),
614 ).toEqual({
615 status: 'started',
616 traceName: 'trace',
617 });
618
619 act(() => {
620 root.render(<Counter count={1} />);
621 });
622
623 expect(getTool('react_stop_profiling').execute({})).toEqual({
624 status: 'stopped',
625 traceName: 'trace',
626 commits: 1,
627 });
628
629 const overview = getTool('react_get_trace_overview').execute({
630 traceName: 'trace',
631 });
632 expect(overview).toHaveLength(1);
633 expect(overview[0].commit).toBe(0);
634
635 // The commit report lists the components that rendered, sorted by actualDuration
636 // descending, with uids assigned in commit-walk order: the host root r0
637 // (widest duration), then Counter r1, then the div r2. Durations are
638 // timing-dependent, so assert identity (uid/name/type) only.
639 const report = getTool('react_get_commit_report').execute({
640 traceName: 'trace',
641 commitIndex: 0,
642 });
643 expect(
644 report.components.map(c => ({
645 uid: c.uid,
646 name: c.name,
647 type: c.type,
648 })),
649 ).toEqual([
650 {uid: 'r0', name: 'createRoot()', type: 'root'},
651 {uid: 'r1', name: 'Counter', type: 'function'},
652 {uid: 'r2', name: 'div', type: 'host'},
653 ]);
654 });
655
656 it('responds synchronously to discovery with the react tool group', () => {
657 const discovered = discover();
658 expect(discovered).not.toBe(null);
659 expect(discovered.name).toBe('react');
660 expect(discovered.tools.map(tool => tool.name)).toEqual(TOOL_NAMES);
661 });
662
663 it('builds the tool group lazily and memoizes it across discoveries', () => {
664 // Repeated discovery returns the same instance, so component uids stay
665 // stable across calls.
666 expect(discover()).toBe(discover());
667 });
668
669 it('unregister removes the discovery listener', () => {
670 unregister();
671 // No listener responds, so chrome-devtools-mcp would discover nothing.
672 expect(discover()).toBe(null);
673 });
674
675 it('tools are callable via window.__dtmcp.executeTool', async () => {
676 function App() {
677 return <div>hello</div>;
678 }
679 act(() => {
680 ReactDOMClient.createRoot(container).render(<App />);
681 });
682
683 // Reproduce chrome-devtools-mcp's exact discovery + execution wiring.
684 const event = new CustomEvent('devtoolstooldiscovery');
685 // $FlowFixMe[prop-missing] chrome-devtools-mcp attaches respondWith
686 event.respondWith = group => {
687 globalThis.__dtmcp = {
688 toolGroup: group,
689 executeTool: async (toolName, args) => {
690 const tool = group.tools.find(t => t.name === toolName);
691 return tool.execute(args);
692 },
693 };
694 };
695 window.dispatchEvent(event);
696
697 const result = await globalThis.__dtmcp.executeTool(
698 'react_get_component_tree',
699 {},
700 );
701 // Same deterministic uids as the direct react_get_component_tree path:
702 // App r0, host root r1, div r2.
703 expect(result).toEqual({
704 nodes: [
705 {
706 uid: 'r1',
707 type: 'root',
708 name: 'createRoot()',
709 key: null,
710 firstChild: 'r0',
711 nextSibling: null,
712 },
713 {
714 uid: 'r0',
715 type: 'function',
716 name: 'App',
717 key: null,
718 firstChild: 'r2',
719 nextSibling: null,
720 },
721 {
722 uid: 'r2',
723 type: 'host',
724 name: 'div',
725 key: null,
726 firstChild: null,
727 nextSibling: null,
728 },
729 ],
730 });
731 });
732 });