/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ 'use strict'; const TOOL_NAMES = [ 'react_get_component_tree', 'react_get_component_by_uid', 'react_get_component_by_dom_element', 'react_find_components', 'react_get_component_source', 'react_get_owner_stack_trace', 'react_get_parent_stack', 'react_get_owner_stack', 'react_start_profiling', 'react_stop_profiling', 'react_get_trace_overview', 'react_get_commit_report', ]; let register; let facade; let toolGroup; let unregister; let React; let ReactDOMClient; let act; let container; // Look up a registered tool by name. function getTool(name) { return toolGroup.tools.find(tool => tool.name === name); } // Dispatch chrome-devtools-mcp's discovery event and return the tool group the // page responds with (synchronously). The tools are built lazily in the // handler, so discovery is what actually constructs them. function discover() { let group = null; const event = new CustomEvent('devtoolstooldiscovery'); // $FlowFixMe[prop-missing] chrome-devtools-mcp attaches respondWith event.respondWith = responded => { group = responded; }; window.dispatchEvent(event); return group; } describe('react-devtools-cdt-mcp', () => { beforeEach(() => { jest.resetModules(); global.IS_REACT_ACT_ENVIRONMENT = true; delete globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__; delete globalThis.__dtmcp; // register() installs the facade BEFORE React so the hook captures the // first commit, then registers the discovery listener (tools are built // lazily when chrome-devtools-mcp discovers them). register = require('../DevToolsCdtMcp').register; const result = register(); facade = result.facade; unregister = result.unregister; // Obtain the tool group the way chrome-devtools-mcp does. toolGroup = discover(); React = require('react'); ReactDOMClient = require('react-dom/client'); act = React.act; container = document.createElement('div'); document.body.appendChild(container); }); afterEach(() => { // Remove the discovery listener so it does not accumulate on the shared // jsdom window across tests. unregister(); document.body.removeChild(container); container = null; }); it('installs the DevTools hook on register', () => { expect(globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__).toBe(facade.hook); }); it('does not install any tool globals (chrome-devtools-mcp owns __dtmcp)', () => { expect(globalThis.__REACT_TOOLS__).toBeUndefined(); expect(globalThis.__dtmcp).toBeUndefined(); }); it('root entry exports tools without registering', () => { unregister(); delete globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__; jest.resetModules(); const api = require('../index'); expect(typeof api.register).toBe('function'); expect(typeof api.buildToolGroup).toBe('function'); expect(globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__).toBeUndefined(); }); it('throws when the register entry is imported outside an event target', () => { const originalAddEventListener = globalThis.addEventListener; const originalRemoveEventListener = globalThis.removeEventListener; unregister(); delete globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__; jest.resetModules(); try { // $FlowFixMe[cannot-write] globalThis.addEventListener = undefined; // $FlowFixMe[cannot-write] globalThis.removeEventListener = undefined; expect(() => require('../register')).toThrow( 'react-devtools-cdt-mcp/register must be imported in a browser-like environment', ); expect(globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__).toBeUndefined(); } finally { globalThis.addEventListener = originalAddEventListener; globalThis.removeEventListener = originalRemoveEventListener; } }); it('register entry installs the DevTools hook', () => { const originalAddEventListener = globalThis.addEventListener; const originalRemoveEventListener = globalThis.removeEventListener; let autoListener = null; unregister(); delete globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__; jest.resetModules(); try { // $FlowFixMe[cannot-write] globalThis.addEventListener = (type, listener, options) => { if (type === 'devtoolstooldiscovery') { autoListener = listener; } return originalAddEventListener.call( globalThis, type, listener, options, ); }; require('../register'); expect(globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__).toBeDefined(); } finally { if (autoListener !== null) { originalRemoveEventListener.call( globalThis, 'devtoolstooldiscovery', autoListener, ); } globalThis.addEventListener = originalAddEventListener; globalThis.removeEventListener = originalRemoveEventListener; } }); it('returns the cached registration for repeated calls per target', () => { let listener = null; const target = { addEventListener: jest.fn((type, callback) => { expect(type).toBe('devtoolstooldiscovery'); listener = callback; }), removeEventListener: jest.fn(), }; const first = register(target); const second = register(target); expect(target.addEventListener).toHaveBeenCalledTimes(1); expect(second).toBe(first); expect(second.facade).toBe(first.facade); let firstGroup = null; let secondGroup = null; listener({ respondWith: group => { firstGroup = group; }, }); listener({ respondWith: group => { secondGroup = group; }, }); expect(secondGroup).toBe(firstGroup); first.unregister(); expect(target.removeEventListener).toHaveBeenCalledTimes(1); expect(target.removeEventListener).toHaveBeenCalledWith( 'devtoolstooldiscovery', listener, ); second.unregister(); expect(target.removeEventListener).toHaveBeenCalledTimes(1); const third = register(target); expect(third).not.toBe(first); expect(target.addEventListener).toHaveBeenCalledTimes(2); third.unregister(); }); it('does not write registration state to the target', () => { let listener = null; const existingHook = { inject: jest.fn(() => 0), onCommitFiberRoot: jest.fn(), onPostCommitFiberRoot: jest.fn(), renderers: new Map(), }; const target = Object.preventExtensions({ __REACT_DEVTOOLS_GLOBAL_HOOK__: existingHook, addEventListener: jest.fn((type, callback) => { expect(type).toBe('devtoolstooldiscovery'); listener = callback; }), removeEventListener: jest.fn(), }); const first = register(target); const second = register(target); expect(target.addEventListener).toHaveBeenCalledTimes(1); expect(second).toBe(first); expect(second.facade).toBe(first.facade); expect(Object.keys(target).sort()).toEqual([ '__REACT_DEVTOOLS_GLOBAL_HOOK__', 'addEventListener', 'removeEventListener', ]); first.unregister(); second.unregister(); expect(target.removeEventListener).toHaveBeenCalledWith( 'devtoolstooldiscovery', listener, ); }); it('builds a "react" tool group exposing every facade tool', () => { expect(toolGroup.name).toBe('react'); expect(typeof toolGroup.description).toBe('string'); expect(toolGroup.description.length).toBeGreaterThan(0); expect(toolGroup.tools.map(tool => tool.name)).toEqual(TOOL_NAMES); toolGroup.tools.forEach(tool => { expect(typeof tool.description).toBe('string'); expect(tool.description.length).toBeGreaterThan(0); expect(tool.inputSchema.type).toBe('object'); expect(typeof tool.execute).toBe('function'); }); expect(getTool('react_get_parent_stack').description).toEqual( expect.stringContaining('Rendered parent chain'), ); expect(getTool('react_get_owner_stack').description).toEqual( expect.stringContaining('Owners describe'), ); }); it('declares JSON-Schema input with required params', () => { expect(getTool('react_get_component_tree').inputSchema).toEqual({ type: 'object', properties: { depth: {type: 'number', description: expect.any(String)}, rootUid: {type: 'string', description: expect.any(String)}, }, }); expect(getTool('react_get_component_by_uid').inputSchema).toEqual({ type: 'object', properties: { uid: {type: 'string', description: expect.any(String)}, includeHooks: {type: 'boolean', description: expect.any(String)}, }, required: ['uid'], }); expect(getTool('react_get_component_by_dom_element').inputSchema).toEqual({ type: 'object', properties: { element: { type: 'object', 'x-mcp-type': 'HTMLElement', description: expect.any(String), }, }, required: ['element'], }); expect(getTool('react_find_components').inputSchema).toEqual({ type: 'object', properties: { name: {type: 'string', description: expect.any(String)}, rootUid: {type: 'string', description: expect.any(String)}, page: {type: 'number', description: expect.any(String)}, pageSize: {type: 'number', description: expect.any(String)}, }, required: ['name'], }); expect(getTool('react_get_parent_stack').inputSchema).toEqual({ type: 'object', properties: {uid: {type: 'string', description: expect.any(String)}}, required: ['uid'], }); expect(getTool('react_get_owner_stack').inputSchema).toEqual({ type: 'object', properties: {uid: {type: 'string', description: expect.any(String)}}, required: ['uid'], }); expect(getTool('react_start_profiling').inputSchema).toEqual({ type: 'object', properties: { traceName: {type: 'string', description: expect.any(String)}, }, }); expect(getTool('react_get_commit_report').inputSchema).toEqual({ type: 'object', properties: { traceName: {type: 'string', description: expect.any(String)}, commitIndex: {type: 'number', description: expect.any(String)}, }, required: ['traceName', 'commitIndex'], }); expect(getTool('react_stop_profiling').inputSchema).toEqual({ type: 'object', properties: {}, }); }); it('react_get_component_tree returns the component tree', () => { function App() { return