From 24fa3f73b5272f89a659733efc84c5d3560b90cb Mon Sep 17 00:00:00 2001 From: Anton Lykhoyda Date: Tue, 26 May 2026 21:20:55 +0200 Subject: [PATCH] feat(cdp-interact): setFieldValue action for React Hook Form fallback (closes #126 Gap A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds cdp_interact action="setFieldValue" — explicit React Hook Form escape hatch for when typeText's onChangeText/onChange descendant walk can't find a typeable handler. Closes Gap A of #126. Background: typeText handles the wrapper-Pressable case (depth-bounded descendant walk for inner TextInput) but fails when a field's value flows through field.onChange → FormProvider context → setValue with no inner TextInput-shaped fiber to find. Common with design-system TextFields built as custom Controllers around + custom rendering, where the agent reports "no handler" but the form IS fillable via context.setValue. API: cdp_interact({ action: 'setFieldValue', testID: 'email-field', // anchor inside the form subtree name: 'email', // RHF field name (matches useController/Controller name) value: 'a@b.com', // any string | number | boolean, passed verbatim shouldValidate: true, // default true; pass-through to setValue options shouldDirty: true, // default true; pass-through to setValue options }) Algorithm (injected-helpers.ts setFieldValue handler): - Match the testID anchor (existing infrastructure) - Walk UP via node.return, bounded at 32 levels / 100 fiber visits - Look for the first Provider fiber whose memoizedProps.value duck-types as UseFormReturn: typeof value.setValue === 'function' AND typeof value.getValues === 'function' AND value.control is an object - Call value.setValue(name, value, {shouldValidate, shouldDirty}) - Surface the thrown message if setValue rejects (bad field name etc.) Closest ancestor wins (natural React context resolution) — nested forms behave intuitively. Why walk-up via fiber.return instead of useFormContext(): injected helpers run OUTSIDE React's rendering cycle, so hooks can't be called. The codebase already uses this pattern (memoizedProps walk) for nav and store context — adding setFieldValue follows the established convention. Why explicit action instead of implicit typeText fallback: the design question was deliberate (per discussion). Implicit fallback would change the meaning of an existing tool call and risk surprising users who expected the existing "no handler" error. Explicit setFieldValue is opt-in, self-contained, and documented in the tool description. Tests (8 new in gh-126-set-field-value.test.js, all green): - happy path: walks up to FormProvider, calls setValue with defaults - shouldValidate/shouldDirty pass through verbatim - numeric and boolean values pass through without coercion - nested-forms: closest provider wins (React context semantics) - missing opts.name → clear error, does NOT walk the tree - no FormProvider ancestor → actionable hint pointing at - setValue throwing → caught + thrown message surfaced - duck-type rejects Providers missing setValue (e.g. ThemeProvider) Other changes: - Helpers bundle version bumped 21 → 22 (existing source-guard test + version-pin assertion both updated) - cdp_interact schema: action enum gains 'setFieldValue' + 'longPress' (latter was already in the description string but missing from the TS type union — fixed for consistency) - New args: name, value, shouldValidate, shouldDirty (all optional; required only for setFieldValue, enforced at handler boundary) Note: codex-pair did not review this commit (the codex MCP server appears disconnected this session — no log entries since May 20). The 8-test coverage + the explicit design approval are the safety net. Verified: 1514/1514 cdp-bridge unit tests passing (+8 net new). Co-Authored-By: Claude Opus 4.7 --- scripts/cdp-bridge/dist/index.js | 10 +- scripts/cdp-bridge/dist/injected-helpers.js | 83 ++++++- scripts/cdp-bridge/dist/tools/interact.js | 16 ++ scripts/cdp-bridge/src/index.ts | 10 +- scripts/cdp-bridge/src/injected-helpers.ts | 83 ++++++- scripts/cdp-bridge/src/tools/interact.ts | 19 +- .../test/unit/gh-126-set-field-value.test.js | 231 ++++++++++++++++++ .../unit/gh-60-bug-5-label-matching.test.js | 4 +- .../test/unit/injected-helpers.test.js | 4 +- 9 files changed, 447 insertions(+), 13 deletions(-) create mode 100644 scripts/cdp-bridge/test/unit/gh-126-set-field-value.test.js diff --git a/scripts/cdp-bridge/dist/index.js b/scripts/cdp-bridge/dist/index.js index 259f6f94..3a5f4a2d 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 d4914ebc..69688de7 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 23e098f8..2920174b 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 e1fee1fc..19c22b74 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 9313b1e1..0dc13c86 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 ab764680..372b140b 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 00000000..cc2dcf2a --- /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 d1ab7a20..f76353c0 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 9aea9926..e40691cd 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/); });