'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;
function getTool(name) {
return toolGroup.tools.find(tool => tool.name === name);
}
function discover() {
let group = null;
const event = new CustomEvent('devtoolstooldiscovery');
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 = require('../DevToolsCdtMcp').register;
const result = register();
facade = result.facade;
unregister = result.unregister;
toolGroup = discover();
React = require('react');
ReactDOMClient = require('react-dom/client');
act = React.act;
container = document.createElement('div');
document.body.appendChild(container);
});
afterEach(() => {
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 {
globalThis.addEventListener = undefined;
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 {
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 <div>hello</div>;
}
act(() => {
ReactDOMClient.createRoot(container).render(<App />);
});
const result = getTool('react_get_component_tree').execute({});
expect(result).toEqual({
nodes: [
{
uid: 'r1',
type: 'root',
name: 'createRoot()',
key: null,
firstChild: 'r0',
nextSibling: null,
},
{
uid: 'r0',
type: 'function',
name: 'App',
key: null,
firstChild: 'r2',
nextSibling: null,
},
{
uid: 'r2',
type: 'host',
name: 'div',
key: null,
firstChild: null,
nextSibling: null,
},
],
});
});
it('react_find_components maps args and returns paginated results', () => {
function Card() {
return <div>card</div>;
}
function App() {
return (
<div>
<Card key="a" />
<Card key="b" />
</div>
);
}
act(() => {
ReactDOMClient.createRoot(container).render(<App />);
});
const result = getTool('react_find_components').execute({name: 'Card'});
expect(result).toEqual({
page: 1,
pageSize: 10,
totalCount: 2,
totalPages: 1,
results: [
{
uid: 'r0',
type: 'function',
name: 'Card',
key: 'a',
firstChild: 'r1',
nextSibling: null,
},
{
uid: 'r2',
type: 'function',
name: 'Card',
key: 'b',
firstChild: 'r3',
nextSibling: null,
},
],
});
});
it('react_get_component_by_uid returns props and hooks when requested', () => {
function Counter() {
const [count] = React.useState(3);
return <div>{count}</div>;
}
act(() => {
ReactDOMClient.createRoot(container).render(<Counter title="hi" />);
});
const result = getTool('react_get_component_tree').execute({});
const tree = result.nodes;
expect(tree).toEqual([
{
uid: 'r1',
type: 'root',
name: 'createRoot()',
key: null,
firstChild: 'r0',
nextSibling: null,
},
{
uid: 'r0',
type: 'function',
name: 'Counter',
key: null,
firstChild: 'r2',
nextSibling: null,
},
{
uid: 'r2',
type: 'host',
name: 'div',
key: null,
firstChild: null,
nextSibling: null,
},
]);
const counter = tree.find(n => n.name === 'Counter');
expect(counter.uid).toBe('r0');
const info = getTool('react_get_component_by_uid').execute({
uid: counter.uid,
includeHooks: true,
});
expect(info).toEqual({
uid: 'r0',
type: 'function',
name: 'Counter',
props: {title: 'hi'},
hooks: [{id: 0, name: 'State', value: 3, subHooks: []}],
});
const infoWithoutHooks = getTool('react_get_component_by_uid').execute({
uid: counter.uid,
});
expect(infoWithoutHooks).toEqual({
uid: 'r0',
type: 'function',
name: 'Counter',
props: {title: 'hi'},
});
});
it('react_get_parent_stack returns structural ancestors', () => {
function Child() {
return <span>leaf</span>;
}
function Owner() {
return (
<section>
<Child />
</section>
);
}
act(() => {
ReactDOMClient.createRoot(container).render(<Owner />);
});
const tree = getTool('react_get_component_tree').execute({}).nodes;
const child = tree.find(n => n.name === 'Child');
expect(getTool('react_get_parent_stack').execute({uid: child.uid})).toEqual(
[
{uid: 'r2', name: 'section', type: 'host'},
{uid: 'r0', name: 'Owner', type: 'function'},
{uid: 'r1', name: expect.any(String), type: 'root'},
],
);
});
it('react_get_component_by_dom_element returns the DOM element component', () => {
function Wrapper({children}) {
return <section className="wrap">{children}</section>;
}
function App() {
return (
<Wrapper>
<button className="action">Run</button>
</Wrapper>
);
}
act(() => {
ReactDOMClient.createRoot(container).render(<App />);
});
const button = container.querySelector('button.action');
const tree = getTool('react_get_component_tree').execute({}).nodes;
const host = tree.find(n => n.name === 'button');
const wrapper = tree.find(n => n.name === 'Wrapper');
const result = getTool('react_get_component_by_dom_element').execute({
element: button,
});
expect(result.uid).toBe(host.uid);
expect(result.uid).not.toBe(wrapper.uid);
expect(result).toMatchObject({
type: 'host',
name: 'button',
props: {className: 'action'},
});
});
it('react_get_component_by_dom_element returns DOM-oriented errors', () => {
expect(getTool('react_get_component_by_dom_element').execute({})).toEqual({
error: 'DOM element is required',
});
act(() => {
ReactDOMClient.createRoot(container).render(<div className="host" />);
});
const unmanaged = document.createElement('span');
expect(
getTool('react_get_component_by_dom_element').execute({
element: unmanaged,
}),
).toEqual({error: 'DOM element is not managed by React'});
});
it('returns tool errors as a raw payload', () => {
const result = getTool('react_get_component_by_uid').execute({
uid: 'r9999',
});
expect(result).toEqual({error: 'Component not found: "r9999"'});
});
it('serializes Error tool payloads with causes', () => {
const {buildToolGroup} = require('../DevToolsCdtMcp');
const group = buildToolGroup({
getComponentByUid: () => ({
error: new Error('Failed to inspect hooks.', {
cause: new Error('Cannot inspect hooks'),
}),
}),
});
const tool = group.tools.find(
item => item.name === 'react_get_component_by_uid',
);
expect(tool.execute({uid: 'r0', includeHooks: true})).toEqual({
error: 'Failed to inspect hooks. Cause: Cannot inspect hooks',
});
});
it('profiling tools record and report commits through the integration', () => {
function Counter({count}) {
return <div>{'Count: ' + count}</div>;
}
const root = ReactDOMClient.createRoot(container);
act(() => {
root.render(<Counter count={0} />);
});
expect(
getTool('react_start_profiling').execute({traceName: 'trace'}),
).toEqual({
status: 'started',
traceName: 'trace',
});
act(() => {
root.render(<Counter count={1} />);
});
expect(getTool('react_stop_profiling').execute({})).toEqual({
status: 'stopped',
traceName: 'trace',
commits: 1,
});
const overview = getTool('react_get_trace_overview').execute({
traceName: 'trace',
});
expect(overview).toHaveLength(1);
expect(overview[0].commit).toBe(0);
const report = getTool('react_get_commit_report').execute({
traceName: 'trace',
commitIndex: 0,
});
expect(
report.components.map(c => ({
uid: c.uid,
name: c.name,
type: c.type,
})),
).toEqual([
{uid: 'r0', name: 'createRoot()', type: 'root'},
{uid: 'r1', name: 'Counter', type: 'function'},
{uid: 'r2', name: 'div', type: 'host'},
]);
});
it('responds synchronously to discovery with the react tool group', () => {
const discovered = discover();
expect(discovered).not.toBe(null);
expect(discovered.name).toBe('react');
expect(discovered.tools.map(tool => tool.name)).toEqual(TOOL_NAMES);
});
it('builds the tool group lazily and memoizes it across discoveries', () => {
expect(discover()).toBe(discover());
});
it('unregister removes the discovery listener', () => {
unregister();
expect(discover()).toBe(null);
});
it('tools are callable via window.__dtmcp.executeTool', async () => {
function App() {
return <div>hello</div>;
}
act(() => {
ReactDOMClient.createRoot(container).render(<App />);
});
const event = new CustomEvent('devtoolstooldiscovery');
event.respondWith = group => {
globalThis.__dtmcp = {
toolGroup: group,
executeTool: async (toolName, args) => {
const tool = group.tools.find(t => t.name === toolName);
return tool.execute(args);
},
};
};
window.dispatchEvent(event);
const result = await globalThis.__dtmcp.executeTool(
'react_get_component_tree',
{},
);
expect(result).toEqual({
nodes: [
{
uid: 'r1',
type: 'root',
name: 'createRoot()',
key: null,
firstChild: 'r0',
nextSibling: null,
},
{
uid: 'r0',
type: 'function',
name: 'App',
key: null,
firstChild: 'r2',
nextSibling: null,
},
{
uid: 'r2',
type: 'host',
name: 'div',
key: null,
firstChild: null,
nextSibling: null,
},
],
});
});
});