Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/get-component-react-agent.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/agent-sdk/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const STATIC_DOMAIN_TOOL_NAMES: Record<string, string[]> = {
react: [
'getTree',
'searchNodes',
'getComponent',
'getNode',
'getChildren',
'getProps',
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/skills/rozenite-agent/domains/react.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ Search and traverse the React component tree, read props, state, and hooks for a

- `searchNodes` -> `{"query":"<query>"}` | `{"query":"<query>","cursor":"<cursor>"}` | `{"query":"<query>","limit":20}`
- `getTree` -> `{}` | `{"depth":2}` | `{"root":123}` | `{"cursor":"<cursor>"}`
- `getComponent` -> `{"id":123}` | `{"nodeId":123}` | `{"id":123,"include":["props"]}`
- `getNode` -> `{"nodeId":123}`
- `getChildren` -> `{"nodeId":123}` | `{"nodeId":123,"cursor":"<cursor>"}` | `{"nodeId":123,"limit":20}`
- `getProps` -> `{"nodeId":123}` | `{"nodeId":123,"cursor":"<cursor>"}` | `{"nodeId":123,"limit":20}`
Expand All @@ -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`.
34 changes: 34 additions & 0 deletions packages/middleware/src/agent/local-domains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down Expand Up @@ -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':
Expand Down
190 changes: 190 additions & 0 deletions packages/middleware/src/agent/runtime/react/__tests__/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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".',
);
});
});
Loading
Loading