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
10 changes: 7 additions & 3 deletions scripts/cdp-bridge/dist/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

83 changes: 82 additions & 1 deletion scripts/cdp-bridge/dist/injected-helpers.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export const INJECTED_HELPERS = `
(function() {
var __HELPERS_VERSION__ = 21;
var __HELPERS_VERSION__ = 22;
if (globalThis.__RN_AGENT && globalThis.__RN_AGENT.__v === __HELPERS_VERSION__) return;
if (globalThis.__RN_AGENT) delete globalThis.__RN_AGENT;

Expand Down Expand Up @@ -1247,6 +1247,87 @@ export const INJECTED_HELPERS = `
});
}

if (action === 'setFieldValue') {
// Issue #126 Gap A — explicit React Hook Form fallback. typeText's
// handler chain walks DOWN looking for a TextInput descendant with
// onChangeText/onChange. That works for wrapper-Pressable patterns
// where the inner TextInput is reachable, but fails when the field's
// value flows through field.onChange → FormProvider context →
// setValue. There's no inner TextInput-shaped fiber to find, because
// the design-system field calls field.onChange directly via a
// Controller render prop.
//
// Resolution: walk UP from the matched fiber (the testID anchor)
// looking for a Provider fiber whose memoizedProps.value duck-types
// as a React Hook Form UseFormReturn. Then call value.setValue(
// name, value, options). The closest ancestor wins (natural React
// context resolution), so nested forms behave intuitively.
var fieldName = opts.name;
var fieldValue = opts.value;
if (typeof fieldName !== 'string' || fieldName.length === 0) {
return JSON.stringify({
error: 'setFieldValue requires opts.name (the RHF field name)',
testID: selector,
hint: 'Pass the same \`name\` string you used in \`useController({ name })\` or \`<Controller name="..." />\`.'
});
}
var shouldValidate = opts.shouldValidate !== false;
var shouldDirty = opts.shouldDirty !== false;

var ANCESTOR_DEPTH_CAP = 32;
var ANCESTOR_VISIT_CAP = 100;
function looksLikeUseFormReturn(v) {
return (
v && typeof v === 'object'
&& typeof v.setValue === 'function'
&& typeof v.getValues === 'function'
&& v.control && typeof v.control === 'object'
);
}
var ancestor = found.return;
var ancestorDepth = 0;
var ancestorVisits = 0;
var formReturn = null;
while (ancestor && ancestorDepth < ANCESTOR_DEPTH_CAP && ancestorVisits < ANCESTOR_VISIT_CAP) {
ancestorVisits++;
var aProps = ancestor.memoizedProps;
if (aProps && looksLikeUseFormReturn(aProps.value)) {
formReturn = aProps.value;
break;
}
ancestor = ancestor.return;
ancestorDepth++;
}
if (!formReturn) {
return JSON.stringify({
error: 'setFieldValue: no FormProvider ancestor found',
testID: selector,
ancestorVisits: ancestorVisits,
hint: 'No React Hook Form FormProvider ancestor with setValue+getValues+control was reachable within ' + ANCESTOR_DEPTH_CAP + ' levels. Either the form is not wrapped in <FormProvider {...methods}>, or the testID anchor sits outside the form subtree. If you only need to fire onChangeText/onChange, use action="typeText" instead.'
});
}
try {
formReturn.setValue(fieldName, fieldValue, { shouldValidate: shouldValidate, shouldDirty: shouldDirty });
} catch (e) {
return JSON.stringify({
error: 'setFieldValue: setValue threw: ' + (e && e.message ? e.message : String(e)),
testID: selector,
name: fieldName,
hint: 'The form was found but its setValue rejected the call. Common causes: name does not exist on the form, value type mismatch, or the form is in a transitioning state.'
});
}
return JSON.stringify({
success: true,
action: 'setFieldValue',
testID: selector,
name: fieldName,
value: fieldValue,
shouldValidate: shouldValidate,
shouldDirty: shouldDirty,
ancestorVisits: ancestorVisits
});
}

if (action === 'scroll') {
var x = opts.scrollX !== undefined ? opts.scrollX : 0;
var y = opts.scrollY !== undefined ? opts.scrollY : 300;
Expand Down
16 changes: 16 additions & 0 deletions scripts/cdp-bridge/dist/tools/interact.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ export function createInteractHandler(getClient) {
if (args.action === 'typeText' && args.text === undefined) {
return failResult('text parameter is required for typeText action');
}
if (args.action === 'setFieldValue') {
if (args.name === undefined || args.name.length === 0) {
return failResult('name parameter is required for setFieldValue action — the React Hook Form field name');
}
if (args.value === undefined) {
return failResult('value parameter is required for setFieldValue action');
}
}
const opts = { action: args.action };
if (args.testID !== undefined)
opts.testID = args.testID;
Expand All @@ -19,6 +27,14 @@ export function createInteractHandler(getClient) {
if (args.scrollY !== undefined)
opts.scrollY = args.scrollY;
opts.animated = args.animated;
if (args.name !== undefined)
opts.name = args.name;
if (args.value !== undefined)
opts.value = args.value;
if (args.shouldValidate !== undefined)
opts.shouldValidate = args.shouldValidate;
if (args.shouldDirty !== undefined)
opts.shouldDirty = args.shouldDirty;
const result = await client.evaluate(`__RN_AGENT.interact(${JSON.stringify(opts)})`);
if (result.error) {
return failResult(`Interact error: ${result.error}`);
Expand Down
10 changes: 7 additions & 3 deletions scripts/cdp-bridge/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,15 +507,19 @@ trackedTool(

trackedTool(
'cdp_interact',
'Interact with React components by testID (preferred) or accessibilityLabel — press buttons, long-press, type text, scroll. Calls JS handlers directly (not native touch). testID matches strictly; accessibilityLabel matches in tiers (exact → trim/case-insensitive → substring) and returns an ambiguity error when >1 component matches. Prefer testID for unambiguous targeting. For native gestures (swipe, drag), use device_swipe/device_press instead.',
'Interact with React components by testID (preferred) or accessibilityLabel — press buttons, long-press, type text, scroll, or set a React Hook Form field value directly. Calls JS handlers directly (not native touch). testID matches strictly; accessibilityLabel matches in tiers (exact → trim/case-insensitive → substring) and returns an ambiguity error when >1 component matches. Prefer testID for unambiguous targeting. For native gestures (swipe, drag), use device_swipe/device_press instead. setFieldValue (GH #126 Gap A): explicit fallback when typeText fails because the field routes through a Controller — pass name + value, walks UP to the nearest FormProvider and calls its setValue. Use only when typeText returns "no handler".',
{
action: z.enum(['press', 'longPress', 'typeText', 'scroll']).describe('press: calls onPress. longPress: calls onLongPress. typeText: calls onChangeText. scroll: calls scrollTo or onScroll.'),
testID: z.string().optional().describe('testID prop of the target component (strict match — preferred)'),
action: z.enum(['press', 'longPress', 'typeText', 'scroll', 'setFieldValue']).describe('press: calls onPress. longPress: calls onLongPress. typeText: calls onChangeText. scroll: calls scrollTo or onScroll. setFieldValue: walks UP to nearest React Hook Form FormProvider and calls setValue(name, value, {shouldValidate, shouldDirty}).'),
testID: z.string().optional().describe('testID prop of the target component (strict match — preferred). For setFieldValue, this is the testID anchor inside the form\'s subtree from which to walk up.'),
accessibilityLabel: z.string().optional().describe('accessibilityLabel prop (used if testID not provided). Tiered match: exact → normalized (trim+lowercase) → substring. Returns Ambiguous error if >1 component matches.'),
text: z.string().optional().describe('Required for typeText: the text to enter'),
scrollX: z.number().optional().describe('For scroll: horizontal offset in pixels (default 0)'),
scrollY: z.number().optional().describe('For scroll: vertical offset in pixels (default 300)'),
animated: z.boolean().default(true).describe('For scroll: whether to animate'),
name: z.string().optional().describe('Required for setFieldValue: the React Hook Form field name (same string you passed to useController({name}) or <Controller name="...">).'),
value: z.union([z.string(), z.number(), z.boolean()]).optional().describe('Required for setFieldValue: the value to set. Passed verbatim to setValue; no coercion.'),
shouldValidate: z.boolean().optional().describe('For setFieldValue: pass-through to setValue\'s options.shouldValidate (default true). Set false to suppress synchronous validation.'),
shouldDirty: z.boolean().optional().describe('For setFieldValue: pass-through to setValue\'s options.shouldDirty (default true). Set false to keep the field marked pristine.'),
},
createInteractHandler(getClient),
);
Expand Down
83 changes: 82 additions & 1 deletion scripts/cdp-bridge/src/injected-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export const INJECTED_HELPERS = `
(function() {
var __HELPERS_VERSION__ = 21;
var __HELPERS_VERSION__ = 22;
if (globalThis.__RN_AGENT && globalThis.__RN_AGENT.__v === __HELPERS_VERSION__) return;
if (globalThis.__RN_AGENT) delete globalThis.__RN_AGENT;

Expand Down Expand Up @@ -1247,6 +1247,87 @@ export const INJECTED_HELPERS = `
});
}

if (action === 'setFieldValue') {
// Issue #126 Gap A — explicit React Hook Form fallback. typeText's
// handler chain walks DOWN looking for a TextInput descendant with
// onChangeText/onChange. That works for wrapper-Pressable patterns
// where the inner TextInput is reachable, but fails when the field's
// value flows through field.onChange → FormProvider context →
// setValue. There's no inner TextInput-shaped fiber to find, because
// the design-system field calls field.onChange directly via a
// Controller render prop.
//
// Resolution: walk UP from the matched fiber (the testID anchor)
// looking for a Provider fiber whose memoizedProps.value duck-types
// as a React Hook Form UseFormReturn. Then call value.setValue(
// name, value, options). The closest ancestor wins (natural React
// context resolution), so nested forms behave intuitively.
var fieldName = opts.name;
var fieldValue = opts.value;
if (typeof fieldName !== 'string' || fieldName.length === 0) {
return JSON.stringify({
error: 'setFieldValue requires opts.name (the RHF field name)',
testID: selector,
hint: 'Pass the same \`name\` string you used in \`useController({ name })\` or \`<Controller name="..." />\`.'
});
}
var shouldValidate = opts.shouldValidate !== false;
var shouldDirty = opts.shouldDirty !== false;

var ANCESTOR_DEPTH_CAP = 32;
var ANCESTOR_VISIT_CAP = 100;
function looksLikeUseFormReturn(v) {
return (
v && typeof v === 'object'
&& typeof v.setValue === 'function'
&& typeof v.getValues === 'function'
&& v.control && typeof v.control === 'object'
);
}
var ancestor = found.return;
var ancestorDepth = 0;
var ancestorVisits = 0;
var formReturn = null;
while (ancestor && ancestorDepth < ANCESTOR_DEPTH_CAP && ancestorVisits < ANCESTOR_VISIT_CAP) {
ancestorVisits++;
var aProps = ancestor.memoizedProps;
if (aProps && looksLikeUseFormReturn(aProps.value)) {
formReturn = aProps.value;
break;
}
ancestor = ancestor.return;
ancestorDepth++;
}
if (!formReturn) {
return JSON.stringify({
error: 'setFieldValue: no FormProvider ancestor found',
testID: selector,
ancestorVisits: ancestorVisits,
hint: 'No React Hook Form FormProvider ancestor with setValue+getValues+control was reachable within ' + ANCESTOR_DEPTH_CAP + ' levels. Either the form is not wrapped in <FormProvider {...methods}>, or the testID anchor sits outside the form subtree. If you only need to fire onChangeText/onChange, use action="typeText" instead.'
});
}
try {
formReturn.setValue(fieldName, fieldValue, { shouldValidate: shouldValidate, shouldDirty: shouldDirty });
} catch (e) {
return JSON.stringify({
error: 'setFieldValue: setValue threw: ' + (e && e.message ? e.message : String(e)),
testID: selector,
name: fieldName,
hint: 'The form was found but its setValue rejected the call. Common causes: name does not exist on the form, value type mismatch, or the form is in a transitioning state.'
});
}
return JSON.stringify({
success: true,
action: 'setFieldValue',
testID: selector,
name: fieldName,
value: fieldValue,
shouldValidate: shouldValidate,
shouldDirty: shouldDirty,
ancestorVisits: ancestorVisits
});
}

if (action === 'scroll') {
var x = opts.scrollX !== undefined ? opts.scrollX : 0;
var y = opts.scrollY !== undefined ? opts.scrollY : 300;
Expand Down
19 changes: 18 additions & 1 deletion scripts/cdp-bridge/src/tools/interact.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { CDPClient } from '../cdp-client.js';
import { okResult, failResult, warnResult, withConnection } from '../utils.js';

type InteractAction = 'press' | 'typeText' | 'scroll';
type InteractAction = 'press' | 'longPress' | 'typeText' | 'scroll' | 'setFieldValue';

interface InteractArgs {
action: InteractAction;
Expand All @@ -11,6 +11,11 @@ interface InteractArgs {
scrollX?: number;
scrollY?: number;
animated: boolean;
// setFieldValue — see injected-helpers.ts setFieldValue handler.
name?: string;
value?: string | number | boolean;
shouldValidate?: boolean;
shouldDirty?: boolean;
}

export function createInteractHandler(getClient: () => CDPClient) {
Expand All @@ -21,6 +26,14 @@ export function createInteractHandler(getClient: () => CDPClient) {
if (args.action === 'typeText' && args.text === undefined) {
return failResult('text parameter is required for typeText action');
}
if (args.action === 'setFieldValue') {
if (args.name === undefined || args.name.length === 0) {
return failResult('name parameter is required for setFieldValue action — the React Hook Form field name');
}
if (args.value === undefined) {
return failResult('value parameter is required for setFieldValue action');
}
}

const opts: Record<string, unknown> = { action: args.action };
if (args.testID !== undefined) opts.testID = args.testID;
Expand All @@ -29,6 +42,10 @@ export function createInteractHandler(getClient: () => CDPClient) {
if (args.scrollX !== undefined) opts.scrollX = args.scrollX;
if (args.scrollY !== undefined) opts.scrollY = args.scrollY;
opts.animated = args.animated;
if (args.name !== undefined) opts.name = args.name;
if (args.value !== undefined) opts.value = args.value;
if (args.shouldValidate !== undefined) opts.shouldValidate = args.shouldValidate;
if (args.shouldDirty !== undefined) opts.shouldDirty = args.shouldDirty;

const result = await client.evaluate(
`__RN_AGENT.interact(${JSON.stringify(opts)})`
Expand Down
Loading
Loading