diff --git a/scripts/cdp-bridge/dist/index.js b/scripts/cdp-bridge/dist/index.js index 259f6f9..3a5f4a2 100644 --- a/scripts/cdp-bridge/dist/index.js +++ b/scripts/cdp-bridge/dist/index.js @@ -326,14 +326,18 @@ trackedTool('cdp_dev_settings', 'Control React Native dev settings programmatica action: z.enum(['reload', 'toggleInspector', 'togglePerfMonitor', 'dismissRedBox', 'disableDevMenu']) .describe('Dev menu action to execute'), }, createDevSettingsHandler(getClient)); -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.', { - 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)'), +trackedTool('cdp_interact', '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', '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 ).'), + 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)); trackedTool('collect_logs', 'Collect logs from multiple sources in parallel: JS console (Hermes ring buffer snapshot), native iOS (xcrun simctl log stream), native Android (adb logcat). Results merged and sorted by timestamp. Works without CDP when only native sources requested. Use when debugging crashes that span JS and native layers.', { sources: z.array(z.enum(['js_console', 'native_ios', 'native_android'])) diff --git a/scripts/cdp-bridge/dist/injected-helpers.js b/scripts/cdp-bridge/dist/injected-helpers.js index d4914eb..69688de 100644 --- a/scripts/cdp-bridge/dist/injected-helpers.js +++ b/scripts/cdp-bridge/dist/injected-helpers.js @@ -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; @@ -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 \`\`.' + }); + } + 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 , 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; diff --git a/scripts/cdp-bridge/dist/tools/interact.js b/scripts/cdp-bridge/dist/tools/interact.js index 23e098f..2920174 100644 --- a/scripts/cdp-bridge/dist/tools/interact.js +++ b/scripts/cdp-bridge/dist/tools/interact.js @@ -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; @@ -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}`); diff --git a/scripts/cdp-bridge/src/index.ts b/scripts/cdp-bridge/src/index.ts index e1fee1f..19c22b7 100644 --- a/scripts/cdp-bridge/src/index.ts +++ b/scripts/cdp-bridge/src/index.ts @@ -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 ).'), + 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), ); diff --git a/scripts/cdp-bridge/src/injected-helpers.ts b/scripts/cdp-bridge/src/injected-helpers.ts index 9313b1e..0dc13c8 100644 --- a/scripts/cdp-bridge/src/injected-helpers.ts +++ b/scripts/cdp-bridge/src/injected-helpers.ts @@ -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; @@ -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 \`\`.' + }); + } + 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 , 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; diff --git a/scripts/cdp-bridge/src/tools/interact.ts b/scripts/cdp-bridge/src/tools/interact.ts index ab76468..372b140 100644 --- a/scripts/cdp-bridge/src/tools/interact.ts +++ b/scripts/cdp-bridge/src/tools/interact.ts @@ -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; @@ -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) { @@ -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 = { action: args.action }; if (args.testID !== undefined) opts.testID = args.testID; @@ -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)})` diff --git a/scripts/cdp-bridge/test/unit/gh-126-set-field-value.test.js b/scripts/cdp-bridge/test/unit/gh-126-set-field-value.test.js new file mode 100644 index 0000000..cc2dcf2 --- /dev/null +++ b/scripts/cdp-bridge/test/unit/gh-126-set-field-value.test.js @@ -0,0 +1,231 @@ +// GH #126 Gap A — explicit React Hook Form fallback action. +// +// cdp_interact action="setFieldValue" walks UP from the matched testID +// fiber looking for a Provider whose memoizedProps.value duck-types as a +// UseFormReturn (has setValue + getValues + control), then calls +// value.setValue(name, value, {shouldValidate, shouldDirty}). This +// unblocks the design-system-TextField + react-hook-form pattern where +// typeText's descendant walk can't find a TextInput-shaped fiber to +// fire onChangeText on (the field's state flows through field.onChange +// → context → setValue, not via a typeable handler). +// +// These tests build synthetic fiber trees in a VM sandbox and verify +// the walk-up + duck-type detection + side-effect call all work. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import vm from 'node:vm'; +import { INJECTED_HELPERS } from '../../dist/injected-helpers.js'; + +/** + * Build a sandbox + fiber root + run __RN_AGENT.interact, returning the + * parsed JSON result. The fiber root layout is controlled by the caller + * (`buildFiber` returns the root fiber; `interactOpts` are forwarded to + * the interact handler). + */ +function runInteract(buildFiber, interactOpts) { + const sandbox = { + Array, Object, JSON, Map, WeakSet, Set, Error, Date, RegExp, Symbol, + parseInt, parseFloat, String, Number, Boolean, Promise, + setTimeout, clearTimeout, + console: { log() {}, error() {}, warn() {}, info() {}, debug() {} }, + }; + sandbox.globalThis = sandbox; + const rootFiber = buildFiber(); + sandbox.__REACT_DEVTOOLS_GLOBAL_HOOK__ = { + renderers: new Map([[1, {}]]), + getFiberRoots: () => new Set([{ current: rootFiber }]), + }; + vm.createContext(sandbox); + vm.runInContext(INJECTED_HELPERS, sandbox); + const out = vm.runInContext( + `__RN_AGENT.interact(${JSON.stringify(interactOpts)})`, + sandbox, + ); + return JSON.parse(out); +} + +/** + * Link parent→child and set up `return` pointers so the walk-up works + * the same way React's fiber tree does in production. + */ +function linkFiber(parent, child) { + parent.child = child; + child.return = parent; + return child; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Happy path — FormProvider ancestor with a duck-typed UseFormReturn +// ───────────────────────────────────────────────────────────────────────────── + +test('setFieldValue: walks up to FormProvider, calls setValue with name + value + default options', () => { + const setValueCalls = []; + const formReturn = { + setValue(name, value, options) { setValueCalls.push({ name, value, options }); }, + getValues() { return {}; }, + control: { _formValues: { email: '' } }, + }; + + const result = runInteract(() => { + // root → FormProvider (the value-carrying Provider) → wrapper → anchor. + const root = { type: { displayName: 'App' }, memoizedProps: {}, child: null, sibling: null, return: null }; + const provider = { type: { displayName: 'FormProvider' }, memoizedProps: { value: formReturn }, child: null, sibling: null, return: null }; + const wrapper = { type: { displayName: 'View' }, memoizedProps: {}, child: null, sibling: null, return: null }; + const anchor = { type: { displayName: 'Pressable' }, memoizedProps: { testID: 'email-field' }, child: null, sibling: null, return: null }; + linkFiber(root, provider); + linkFiber(provider, wrapper); + linkFiber(wrapper, anchor); + return root; + }, { action: 'setFieldValue', testID: 'email-field', name: 'email', value: 'a@b.com' }); + + assert.equal(result.success, true); + assert.equal(result.action, 'setFieldValue'); + assert.equal(result.name, 'email'); + assert.equal(result.value, 'a@b.com'); + assert.equal(setValueCalls.length, 1); + // JSON-roundtrip to coerce sandbox-side Objects to Node-side Objects, + // so assert/strict's prototype check doesn't reject structurally-equal + // values for not being reference-equal across the VM boundary. + assert.deepEqual(JSON.parse(JSON.stringify(setValueCalls[0])), { + name: 'email', + value: 'a@b.com', + options: { shouldValidate: true, shouldDirty: true }, + }); +}); + +test('setFieldValue: shouldValidate=false and shouldDirty=false pass through verbatim', () => { + const setValueCalls = []; + const formReturn = { + setValue(name, value, options) { setValueCalls.push({ name, value, options }); }, + getValues() { return {}; }, + control: { _formValues: {} }, + }; + const result = runInteract(() => { + const root = { type: {}, memoizedProps: {}, child: null, sibling: null, return: null }; + const provider = { type: { displayName: 'FormProvider' }, memoizedProps: { value: formReturn }, child: null, sibling: null, return: null }; + const anchor = { type: {}, memoizedProps: { testID: 'f' }, child: null, sibling: null, return: null }; + linkFiber(root, provider); + linkFiber(provider, anchor); + return root; + }, { action: 'setFieldValue', testID: 'f', name: 'n', value: 'v', shouldValidate: false, shouldDirty: false }); + + assert.equal(result.success, true); + assert.deepEqual(JSON.parse(JSON.stringify(setValueCalls[0].options)), { shouldValidate: false, shouldDirty: false }); +}); + +test('setFieldValue: numeric and boolean values pass through unchanged (no coercion)', () => { + const calls = []; + const formReturn = { + setValue(n, v, o) { calls.push({ v, type: typeof v }); }, + getValues() { return {}; }, + control: {}, + }; + function buildTree() { + const root = { type: {}, memoizedProps: {}, child: null, sibling: null, return: null }; + const provider = { type: { displayName: 'FormProvider' }, memoizedProps: { value: formReturn }, child: null, sibling: null, return: null }; + const anchor = { type: {}, memoizedProps: { testID: 'f' }, child: null, sibling: null, return: null }; + linkFiber(root, provider); + linkFiber(provider, anchor); + return root; + } + runInteract(buildTree, { action: 'setFieldValue', testID: 'f', name: 'age', value: 42 }); + runInteract(buildTree, { action: 'setFieldValue', testID: 'f', name: 'opt-in', value: true }); + assert.deepEqual(calls, [ + { v: 42, type: 'number' }, + { v: true, type: 'boolean' }, + ]); +}); + +test('setFieldValue: nearest FormProvider wins (nested forms behave like React context)', () => { + const outerCalls = []; + const innerCalls = []; + const outer = { setValue(n, v) { outerCalls.push({ n, v }); }, getValues() { return {}; }, control: {} }; + const inner = { setValue(n, v) { innerCalls.push({ n, v }); }, getValues() { return {}; }, control: {} }; + const result = runInteract(() => { + const root = { type: {}, memoizedProps: {}, child: null, sibling: null, return: null }; + const outerProvider = { type: { displayName: 'FormProvider' }, memoizedProps: { value: outer }, child: null, sibling: null, return: null }; + const innerProvider = { type: { displayName: 'FormProvider' }, memoizedProps: { value: inner }, child: null, sibling: null, return: null }; + const anchor = { type: {}, memoizedProps: { testID: 'inner-field' }, child: null, sibling: null, return: null }; + linkFiber(root, outerProvider); + linkFiber(outerProvider, innerProvider); + linkFiber(innerProvider, anchor); + return root; + }, { action: 'setFieldValue', testID: 'inner-field', name: 'x', value: '1' }); + + assert.equal(result.success, true); + assert.equal(innerCalls.length, 1, 'closest provider should win'); + assert.equal(outerCalls.length, 0, 'outer provider should not see the call'); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Failure paths +// ───────────────────────────────────────────────────────────────────────────── + +test('setFieldValue: missing opts.name returns clear error (does NOT walk the tree)', () => { + const result = runInteract(() => { + const root = { type: {}, memoizedProps: {}, child: null, sibling: null, return: null }; + const anchor = { type: {}, memoizedProps: { testID: 'f' }, child: null, sibling: null, return: null }; + linkFiber(root, anchor); + return root; + }, { action: 'setFieldValue', testID: 'f', value: 'x' }); + + assert.match(result.error, /requires opts\.name/); + assert.equal(result.testID, 'f'); +}); + +test('setFieldValue: no FormProvider ancestor returns actionable hint', () => { + // A tree with no Provider whose value duck-types as UseFormReturn. + const result = runInteract(() => { + const root = { type: {}, memoizedProps: {}, child: null, sibling: null, return: null }; + // A Provider whose value is NOT a form return — must be ignored. + const wrongProvider = { type: { displayName: 'ThemeProvider' }, memoizedProps: { value: { theme: 'dark' } }, child: null, sibling: null, return: null }; + const anchor = { type: {}, memoizedProps: { testID: 'f' }, child: null, sibling: null, return: null }; + linkFiber(root, wrongProvider); + linkFiber(wrongProvider, anchor); + return root; + }, { action: 'setFieldValue', testID: 'f', name: 'email', value: 'x' }); + + assert.match(result.error, /no FormProvider ancestor/); + assert.match(result.hint, /not wrapped in { + const formReturn = { + setValue() { throw new Error('field does not exist on the form'); }, + getValues() { return {}; }, + control: {}, + }; + const result = runInteract(() => { + const root = { type: {}, memoizedProps: {}, child: null, sibling: null, return: null }; + const provider = { type: { displayName: 'FormProvider' }, memoizedProps: { value: formReturn }, child: null, sibling: null, return: null }; + const anchor = { type: {}, memoizedProps: { testID: 'f' }, child: null, sibling: null, return: null }; + linkFiber(root, provider); + linkFiber(provider, anchor); + return root; + }, { action: 'setFieldValue', testID: 'f', name: 'bogus', value: 'x' }); + + assert.match(result.error, /setValue threw: field does not exist/); + assert.equal(result.name, 'bogus'); +}); + +test('setFieldValue: duck-type rejects a Provider whose value lacks setValue (not a form return)', () => { + // Critical: many context Providers have `value` objects with `getValues` + // or `control` look-alike fields. The duck-type MUST require ALL three + // (setValue + getValues + control) before assuming it's a form return. + const partialForm = { + // setValue missing + getValues() { return {}; }, + control: { _formValues: {} }, + }; + const result = runInteract(() => { + const root = { type: {}, memoizedProps: {}, child: null, sibling: null, return: null }; + const provider = { type: { displayName: 'PartialFormProvider' }, memoizedProps: { value: partialForm }, child: null, sibling: null, return: null }; + const anchor = { type: {}, memoizedProps: { testID: 'f' }, child: null, sibling: null, return: null }; + linkFiber(root, provider); + linkFiber(provider, anchor); + return root; + }, { action: 'setFieldValue', testID: 'f', name: 'x', value: 'y' }); + + assert.match(result.error, /no FormProvider ancestor/); +}); diff --git a/scripts/cdp-bridge/test/unit/gh-60-bug-5-label-matching.test.js b/scripts/cdp-bridge/test/unit/gh-60-bug-5-label-matching.test.js index d1ab7a2..f76353c 100644 --- a/scripts/cdp-bridge/test/unit/gh-60-bug-5-label-matching.test.js +++ b/scripts/cdp-bridge/test/unit/gh-60-bug-5-label-matching.test.js @@ -324,6 +324,6 @@ test('source guard: getTree filter checks accessibilityLabel', () => { assert.match(INJECTED_HELPERS, /matchesName \|\| matchesTestID \|\| matchesLabel/); }); -test('source guard: helpers version bumped to 21', () => { - assert.match(INJECTED_HELPERS, /__HELPERS_VERSION__ = 21;/); +test('source guard: helpers version bumped to 22 (setFieldValue, GH #126 Gap A)', () => { + assert.match(INJECTED_HELPERS, /__HELPERS_VERSION__ = 22;/); }); diff --git a/scripts/cdp-bridge/test/unit/injected-helpers.test.js b/scripts/cdp-bridge/test/unit/injected-helpers.test.js index 9aea992..e40691c 100644 --- a/scripts/cdp-bridge/test/unit/injected-helpers.test.js +++ b/scripts/cdp-bridge/test/unit/injected-helpers.test.js @@ -317,6 +317,6 @@ test('M10: getAppInfo returns architecture=unknown when nativeFabricUIManager is assert.equal(info.architecture, 'unknown'); }); -test('Issue #126: helpers bundle version is 21 (bumped for typeText descendant walk + MAX_RENDERER_IDS=20)', () => { - assert.match(INJECTED_HELPERS, /__HELPERS_VERSION__\s*=\s*21/); +test('Issue #126 Gap A: helpers bundle version is 22 (bumped for setFieldValue action)', () => { + assert.match(INJECTED_HELPERS, /__HELPERS_VERSION__\s*=\s*22/); });