diff --git a/.changeset/get-component-react-agent.md b/.changeset/get-component-react-agent.md new file mode 100644 index 00000000..3b6cf927 --- /dev/null +++ b/.changeset/get-component-react-agent.md @@ -0,0 +1,6 @@ +--- +'@rozenite/middleware': patch +'rozenite': patch +--- + +Add `getComponent` to the React agent so inspected component data can be fetched from a live session. diff --git a/packages/agent-sdk/src/constants.ts b/packages/agent-sdk/src/constants.ts index 63c2b6fe..56a1d81d 100644 --- a/packages/agent-sdk/src/constants.ts +++ b/packages/agent-sdk/src/constants.ts @@ -43,6 +43,7 @@ export const STATIC_DOMAIN_TOOL_NAMES: Record = { react: [ 'getTree', 'searchNodes', + 'getComponent', 'getNode', 'getChildren', 'getProps', diff --git a/packages/cli/skills/rozenite-agent/domains/react.md b/packages/cli/skills/rozenite-agent/domains/react.md index 4407fe42..557eecb2 100644 --- a/packages/cli/skills/rozenite-agent/domains/react.md +++ b/packages/cli/skills/rozenite-agent/domains/react.md @@ -4,6 +4,7 @@ Search and traverse the React component tree, read props, state, and hooks for a - `searchNodes` -> `{"query":""}` | `{"query":"","cursor":""}` | `{"query":"","limit":20}` - `getTree` -> `{}` | `{"depth":2}` | `{"root":123}` | `{"cursor":""}` +- `getComponent` -> `{"id":123}` | `{"nodeId":123}` | `{"id":123,"include":["props"]}` - `getNode` -> `{"nodeId":123}` - `getChildren` -> `{"nodeId":123}` | `{"nodeId":123,"cursor":""}` | `{"nodeId":123,"limit":20}` - `getProps` -> `{"nodeId":123}` | `{"nodeId":123,"cursor":""}` | `{"nodeId":123,"limit":20}` @@ -17,7 +18,7 @@ Search and traverse the React component tree, read props, state, and hooks for a ## Flow Search and inspect: -`getTree` / `searchNodes` -> `getNode` / `getChildren` -> `getProps` / `getState` / `getHooks`. +`getTree` / `searchNodes` -> `getComponent` / `getNode` / `getChildren` -> `getProps` / `getState` / `getHooks`. Profile: `startProfiling` -> reproduce interaction -> `stopProfiling` -> `getRenderData`. diff --git a/packages/middleware/src/agent/local-domains.ts b/packages/middleware/src/agent/local-domains.ts index 9f0c19da..781573ef 100644 --- a/packages/middleware/src/agent/local-domains.ts +++ b/packages/middleware/src/agent/local-domains.ts @@ -681,6 +681,38 @@ export const createReactDomainService = (deps: { }, }, }, + { + name: 'getComponent', + description: + 'Get a React node summary plus inspected props, state, and hooks in one response.', + inputSchema: { + type: 'object', + properties: { + id: { + ...nodeIdentifierSchema, + description: 'React DevTools node ID or component label.', + }, + nodeId: { + ...nodeIdentifierSchema, + description: 'React DevTools node ID or component label.', + }, + include: { + type: 'array', + items: { + type: 'string', + enum: ['props', 'state', 'hooks'], + }, + description: + 'Optional sections to include. Defaults to props, state, and hooks.', + }, + valueDepth: { + type: 'integer', + description: 'Max nested serialization depth. Default 4, max 8.', + }, + }, + anyOf: [{ required: ['id'] }, { required: ['nodeId'] }], + }, + }, { name: 'getNode', description: 'Get a single React node summary by node ID or label.', @@ -941,6 +973,8 @@ export const createReactDomainService = (deps: { switch (toolName) { case 'getTree': return store.getTree(sessionDeviceId, args); + case 'getComponent': + return store.getComponent(sessionDeviceId, args); case 'getNode': return store.getNode(sessionDeviceId, args); case 'getChildren': diff --git a/packages/middleware/src/agent/runtime/react/__tests__/store.test.ts b/packages/middleware/src/agent/runtime/react/__tests__/store.test.ts index e0ec2a61..c1d99dd0 100644 --- a/packages/middleware/src/agent/runtime/react/__tests__/store.test.ts +++ b/packages/middleware/src/agent/runtime/react/__tests__/store.test.ts @@ -47,6 +47,42 @@ const createStoreWithBridgeStub = ( }); }; +const createStoreWithComponent = () => { + const sent: Array<{ event: string; payload: unknown }> = []; + const store = createStoreWithBridgeStub(sent); + store.registerDevice(DEVICE_ID, { + sendMessage: () => undefined, + }); + store.syncTree(DEVICE_ID, { + roots: [1], + nodes: [ + { + nodeId: 1, + displayName: 'Root', + elementType: 'root', + childIds: [2], + }, + { + nodeId: 2, + displayName: 'App', + elementType: 'function', + parentId: 1, + rendererId: 7, + childIds: [3], + }, + { + nodeId: 3, + displayName: 'Button', + elementType: 'host', + parentId: 2, + childIds: [], + }, + ], + }); + + return { store, sent }; +}; + const createStoreWithTree = () => { const store = createReactTreeStore(); store.registerDevice(DEVICE_ID); @@ -322,3 +358,157 @@ describe('React tree store labels', () => { ); }); }); + +describe('React tree store getComponent', () => { + it('returns a node summary with inspected props, state, and hooks', async () => { + const { store, sent } = createStoreWithComponent(); + + const resultPromise = store.getComponent(DEVICE_ID, { id: 2 }); + await waitFor(() => sent.length > 0); + + expect(sent.at(-1)).toEqual({ + event: 'inspectElement', + payload: { + forceFullData: true, + id: 2, + path: null, + rendererID: 7, + requestID: 1, + }, + }); + + await store.ingestReactDevToolsMessage(DEVICE_ID, { + event: 'inspectedElement', + payload: { + id: 2, + type: 'full-data', + value: { + props: { title: 'Hello' }, + state: { count: 1 }, + hooks: [{ name: 'State', value: 'ready' }], + }, + }, + }); + + await expect(resultPromise).resolves.toMatchObject({ + node: { + nodeId: 2, + label: '@c2', + parentLabel: '@c1', + displayName: 'App', + elementType: 'function', + childIds: [3], + rendererId: 7, + }, + props: { title: 'Hello' }, + state: { count: 1 }, + hooks: [{ name: 'State', value: 'ready' }], + }); + }); + + it('returns only requested sections', async () => { + const { store, sent } = createStoreWithComponent(); + + const resultPromise = store.getComponent(DEVICE_ID, { + nodeId: 2, + include: ['props'], + }); + await waitFor(() => sent.length > 0); + await store.ingestReactDevToolsMessage(DEVICE_ID, { + event: 'inspectedElement', + payload: { + id: 2, + type: 'full-data', + value: { + props: { title: 'Hello' }, + state: { count: 1 }, + hooks: [{ name: 'State', value: 'ready' }], + }, + }, + }); + + await expect(resultPromise).resolves.toEqual( + expect.not.objectContaining({ + state: expect.anything(), + hooks: expect.anything(), + }), + ); + await expect(resultPromise).resolves.toMatchObject({ + props: { title: 'Hello' }, + }); + }); + + it('marks the response partial when requested sections are unavailable', async () => { + const { store, sent } = createStoreWithComponent(); + + const resultPromise = store.getComponent(DEVICE_ID, { id: 2 }); + await waitFor(() => sent.length > 0); + await store.ingestReactDevToolsMessage(DEVICE_ID, { + event: 'inspectedElement', + payload: { + id: 2, + type: 'full-data', + value: { + props: { title: 'Hello' }, + }, + }, + }); + + await expect(resultPromise).resolves.toMatchObject({ + props: { title: 'Hello' }, + partial: true, + unavailable: ['state', 'hooks'], + }); + }); + + it('bounds serialized nested values', async () => { + const { store, sent } = createStoreWithComponent(); + + const resultPromise = store.getComponent(DEVICE_ID, { + id: 2, + include: ['props'], + valueDepth: 1, + }); + await waitFor(() => sent.length > 0); + await store.ingestReactDevToolsMessage(DEVICE_ID, { + event: 'inspectedElement', + payload: { + id: 2, + type: 'full-data', + value: { + props: { + nested: { + value: 'hidden', + }, + onPress: () => undefined, + }, + }, + }, + }); + + await expect(resultPromise).resolves.toMatchObject({ + props: { + nested: '[object]', + onPress: '[function]', + }, + }); + }); + + it('throws when React DevTools returns no inspected data', async () => { + const { store, sent } = createStoreWithComponent(); + + const resultPromise = store.getComponent(DEVICE_ID, { id: 2 }); + await waitFor(() => sent.length > 0); + await store.ingestReactDevToolsMessage(DEVICE_ID, { + event: 'inspectedElement', + payload: { + id: 2, + type: 'not-found', + }, + }); + + await expect(resultPromise).rejects.toThrow( + 'No inspected snapshot available for node "2".', + ); + }); +}); diff --git a/packages/middleware/src/agent/runtime/react/store.ts b/packages/middleware/src/agent/runtime/react/store.ts index d909e063..a83d5ba0 100644 --- a/packages/middleware/src/agent/runtime/react/store.ts +++ b/packages/middleware/src/agent/runtime/react/store.ts @@ -1,7 +1,9 @@ import { hashFilters } from '../pagination/cursor.js'; import type { + ReactComponentSection, ReactDevToolsBridgeMessage, ReactGetChildrenResult, + ReactGetComponentResult, ReactGetInspectableResult, ReactGetRenderDataResult, ReactGetTreeResult, @@ -29,6 +31,8 @@ const MAX_SEARCH_LIMIT = 100; const GET_TREE_TOOL_NAME = 'getTree'; const SEARCH_TOOL_NAME = 'searchNodes'; const GET_CHILDREN_TOOL_NAME = 'getChildren'; +const DEFAULT_COMPONENT_VALUE_DEPTH = 4; +const MAX_COMPONENT_VALUE_DEPTH = 8; const GET_PROPS_TOOL_NAME = 'getProps'; const GET_STATE_TOOL_NAME = 'getState'; const GET_HOOKS_TOOL_NAME = 'getHooks'; @@ -201,6 +205,48 @@ const normalizeRenderDataSort = (value: unknown): ReactRenderDataSort => { return 'duration-desc'; }; +const normalizeComponentSections = (value: unknown): ReactComponentSection[] => { + const defaultSections: ReactComponentSection[] = ['props', 'state', 'hooks']; + if (value === undefined) { + return defaultSections; + } + + if (!Array.isArray(value)) { + throw new Error('"include" must be an array of props, state, or hooks'); + } + + const sections: ReactComponentSection[] = []; + for (const entry of value) { + if (entry !== 'props' && entry !== 'state' && entry !== 'hooks') { + throw new Error('"include" must contain only props, state, or hooks'); + } + if (!sections.includes(entry)) { + sections.push(entry); + } + } + + if (sections.length === 0) { + throw new Error('"include" must contain at least one section'); + } + + return sections; +}; + +const normalizeComponentValueDepth = (value: unknown): number => { + if (value === undefined) { + return DEFAULT_COMPONENT_VALUE_DEPTH; + } + + const parsed = Number(value); + if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 0) { + throw new Error( + `"valueDepth" must be an integer between 0 and ${MAX_COMPONENT_VALUE_DEPTH}`, + ); + } + + return Math.min(parsed, MAX_COMPONENT_VALUE_DEPTH); +}; + const delay = async (ms: number): Promise => { await new Promise((resolve) => { setTimeout(resolve, ms); @@ -1234,6 +1280,74 @@ export const createReactTreeStore = (options?: { }; }; + const getInspectedRecord = async ( + state: DeviceReactTreeState, + nodeId: number, + sections: ReactComponentSection[], + ): Promise => { + let inspected = state.inspectedById.get(nodeId); + const hasAllRequestedSections = sections.every((section) => { + return inspected?.[section] !== undefined; + }); + + if (!inspected || !hasAllRequestedSections) { + inspected = await requestInspectableSnapshot(state, nodeId) + || state.inspectedById.get(nodeId); + } + + return inspected ?? null; + }; + + const getComponent = async ( + deviceId: string, + rawRequest: unknown, + ): Promise => { + const request = getRecord(rawRequest) || {}; + const state = getOrCreateState(deviceId); + const nodeId = getRequestedNodeId(state, request); + const node = ensureNodeExists(state, nodeId); + const sections = normalizeComponentSections(request.include); + const valueDepth = normalizeComponentValueDepth(request.valueDepth); + const inspected = await getInspectedRecord(state, nodeId, sections); + + if (!inspected) { + throw new Error( + `No inspected snapshot available for node "${nodeId}". React DevTools did not return inspected data for this node.`, + ); + } + + const unavailable: ReactComponentSection[] = []; + const result: ReactGetComponentResult = { + node: { + ...ensureNodeSummaryForState(state, node), + childIds: node.childIds.filter((childId) => state.nodesById.has(childId)), + ...(node.rendererId !== undefined ? { rendererId: node.rendererId } : {}), + }, + }; + + for (const section of sections) { + const value = inspected[section]; + if (value === undefined) { + unavailable.push(section); + continue; + } + + result[section] = createSerializableSnapshot(value, valueDepth); + } + + if (unavailable.length === sections.length) { + throw new Error( + `No requested component snapshot sections available for node "${nodeId}".`, + ); + } + if (unavailable.length > 0) { + result.partial = true; + result.unavailable = unavailable; + } + + return result; + }; + const requestInspectableSnapshot = async ( state: DeviceReactTreeState, nodeId: number, @@ -1553,6 +1667,7 @@ export const createReactTreeStore = (options?: { stopProfiling, getRenderData, getTree, + getComponent, searchNodes, getNode, getChildren, diff --git a/packages/middleware/src/agent/runtime/react/types.ts b/packages/middleware/src/agent/runtime/react/types.ts index e1223fde..be0b8ce6 100644 --- a/packages/middleware/src/agent/runtime/react/types.ts +++ b/packages/middleware/src/agent/runtime/react/types.ts @@ -80,6 +80,20 @@ export interface ReactGetChildrenResult { }; } +export type ReactComponentSection = 'props' | 'state' | 'hooks'; + +export interface ReactGetComponentResult { + node: ReactNodeSummary & { + childIds: number[]; + rendererId?: number; + }; + props?: unknown; + state?: unknown; + hooks?: unknown; + partial?: boolean; + unavailable?: ReactComponentSection[]; +} + export interface ReactInspectableEntry { name: string; value: unknown;