diff --git a/packages/dynamic-client/src/instruction-encoding/visitors/account-default-value.ts b/packages/dynamic-client/src/instruction-encoding/visitors/account-default-value.ts index 89a8e0f88..f2d9a5b86 100644 --- a/packages/dynamic-client/src/instruction-encoding/visitors/account-default-value.ts +++ b/packages/dynamic-client/src/instruction-encoding/visitors/account-default-value.ts @@ -35,6 +35,7 @@ import { resolveAccountValueNodeAddress } from '../resolvers/resolve-account-val import { resolveConditionalValueNodeCondition } from '../resolvers/resolve-conditional'; import { resolvePDAAddress } from '../resolvers/resolve-pda-address'; import type { BaseResolutionContext } from '../resolvers/types'; +import { formatArgumentPathSuffix, resolveArgumentPathValue } from './resolve-argument-path'; type AccountDefaultValueVisitorContext = BaseResolutionContext & { accountAddressInput: AddressInput | null | undefined; @@ -94,10 +95,15 @@ export function createAccountDefaultValueVisitor( }, visitArgumentValue: async (node: ArgumentValueNode) => { - const argValue = argumentsInput?.[node.name]; + const rootArg = argumentsInput?.[node.name]; + const argValue = + node.path && node.path.length > 0 + ? resolveArgumentPathValue(rootArg, node.path, node.name, ixNode.name) + : rootArg; if (argValue === undefined || argValue === null) { throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__ARGUMENT_MISSING, { argumentName: node.name, + argumentPath: formatArgumentPathSuffix(node.path ?? []), instructionName: ixNode.name, }); } diff --git a/packages/dynamic-client/src/instruction-encoding/visitors/condition-node-value.ts b/packages/dynamic-client/src/instruction-encoding/visitors/condition-node-value.ts index 47bc5f56d..ddd957129 100644 --- a/packages/dynamic-client/src/instruction-encoding/visitors/condition-node-value.ts +++ b/packages/dynamic-client/src/instruction-encoding/visitors/condition-node-value.ts @@ -4,6 +4,7 @@ import type { AccountValueNode, ArgumentValueNode, ResolverValueNode } from 'cod import { resolveAccountValueNodeAddress } from '../resolvers/resolve-account-value-node-address'; import type { BaseResolutionContext } from '../resolvers/types'; +import { resolveArgumentPathValue } from './resolve-argument-path'; export const CONDITION_NODE_SUPPORTED_NODE_KINDS = [ 'accountValueNode', @@ -42,7 +43,15 @@ export function createConditionNodeValueVisitor( }, visitArgumentValue: async (node: ArgumentValueNode) => { - const argInput = argumentsInput?.[node.name]; + const rootArg = argumentsInput?.[node.name]; + // Conditions compare runtime values, not encoded bytes — we deliberately do not + // recompute the leaf field's TypeNode here. If conditional comparison ever grows a + // wire-typed dimension (e.g. comparing encoded discriminators), this branch must also + // walk ixArgumentNode.type via resolveArgumentPathType to pick the right type. + const argInput = + node.path && node.path.length > 0 + ? resolveArgumentPathValue(rootArg, node.path, node.name, ixNode.name) + : rootArg; return await Promise.resolve(argInput); }, diff --git a/packages/dynamic-client/src/instruction-encoding/visitors/pda-seed-value.ts b/packages/dynamic-client/src/instruction-encoding/visitors/pda-seed-value.ts index 25b00da66..f0eeedc2a 100644 --- a/packages/dynamic-client/src/instruction-encoding/visitors/pda-seed-value.ts +++ b/packages/dynamic-client/src/instruction-encoding/visitors/pda-seed-value.ts @@ -32,6 +32,7 @@ import { getMemoizedAddressEncoder, getMemoizedBooleanEncoder, getMemoizedUtf8Co import { resolveAccountValueNodeAddress } from '../resolvers/resolve-account-value-node-address'; import type { BaseResolutionContext } from '../resolvers/types'; import { createInputValueTransformer } from './input-value-transformer'; +import { formatArgumentPathSuffix, resolveArgumentPathType, resolveArgumentPathValue } from './resolve-argument-path'; export const PDA_SEED_VALUE_SUPPORTED_NODE_KINDS = [ 'accountValueNode', @@ -96,12 +97,20 @@ export function createPdaSeedValueVisitor( referencedName: node.name, }); } - const argInput = argumentsInput[node.name]; + + const argFieldType = + node.path && node.path.length > 0 + ? resolveArgumentPathType(ixArgumentNode.type, node.path, root, node.name) + : ixArgumentNode.type; + const argInput = + node.path && node.path.length > 0 + ? resolveArgumentPathValue(argumentsInput[node.name], node.path, node.name, ixNode.name) + : argumentsInput[node.name]; // Use the PDA seed's declared type (e.g. plain stringTypeNode) rather than - // the instruction argument's type (e.g. sizePrefixTypeNode) so the seed - // bytes match what the on-chain program derives. - const typeNode = seedTypeNode ?? ixArgumentNode.type; + // the (nested) instruction argument's type (e.g. sizePrefixTypeNode) so the + // seed bytes match what the on-chain program derives. + const typeNode = seedTypeNode ?? argFieldType; if (argInput === undefined || argInput === null) { // optional remainderOptionTypeNode seeds encodes to zero bytes. @@ -110,6 +119,7 @@ export function createPdaSeedValueVisitor( } throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__ARGUMENT_MISSING, { argumentName: node.name, + argumentPath: formatArgumentPathSuffix(node.path ?? []), instructionName: ixNode.name, }); } diff --git a/packages/dynamic-client/src/instruction-encoding/visitors/resolve-argument-path.ts b/packages/dynamic-client/src/instruction-encoding/visitors/resolve-argument-path.ts new file mode 100644 index 000000000..656c103bc --- /dev/null +++ b/packages/dynamic-client/src/instruction-encoding/visitors/resolve-argument-path.ts @@ -0,0 +1,112 @@ +import { + CODAMA_ERROR__DYNAMIC_CLIENT__ARGUMENT_MISSING, + CODAMA_ERROR__DYNAMIC_CLIENT__INVARIANT_VIOLATION, + CODAMA_ERROR__LINKED_NODE_NOT_FOUND, + CodamaError, +} from '@codama/errors'; +import type { ArgumentValueNode, CamelCaseString, RootNode, TypeNode } from 'codama'; +import { isNode } from 'codama'; + +import { isObjectRecord } from '../../shared/util'; + +/** + * Format an ArgumentValueNode reference as a dotted display string. + * Example: `{ name: "planData", path: ["planId"] }` → `"planData.planId"`. + */ +export function formatArgumentReference(node: ArgumentValueNode): string { + return node.path && node.path.length > 0 ? `${node.name}.${node.path.join('.')}` : node.name; +} + +/** + * Format a path array as the `argumentPath` suffix expected by ARGUMENT_MISSING error context. + * Empty/missing path → "" (so the error message renders just the argument name). + */ +function pathSuffix(path: readonly CamelCaseString[]): string { + return path.length > 0 ? `.${path.join('.')}` : ''; +} + +/** + * Walks `path` through a top-level instruction-arg type to the leaf field's typeNode. + * Descends through structTypeNode fields and resolves definedTypeLinkNode along the way. + * Throws INVARIANT_VIOLATION if the path doesn't resolve through a struct field. + */ +export function resolveArgumentPathType( + rootType: TypeNode, + path: readonly CamelCaseString[], + root: RootNode, + argumentName: CamelCaseString, +): TypeNode { + let current = rootType; + const visited: CamelCaseString[] = []; + for (const segment of path) { + current = unwrapDefinedTypeLink(current, root); + if (!isNode(current, 'structTypeNode')) { + throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__INVARIANT_VIOLATION, { + message: `Cannot walk argument path "${argumentName}${pathSuffix([...visited, segment])}": expected structTypeNode at "${argumentName}${pathSuffix(visited)}", got ${current.kind}.`, + }); + } + const field = current.fields.find(f => f.name === segment); + if (!field) { + throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__INVARIANT_VIOLATION, { + message: `Argument path "${argumentName}${pathSuffix([...visited, segment])}" does not exist: struct has no field "${segment}".`, + }); + } + current = field.type; + visited.push(segment); + } + return current; +} + +/** + * Walks `path` through a top-level argument input value to the leaf value. + * Each step requires the intermediate value to be a non-null object. + * Throws ARGUMENT_MISSING if any intermediate (or the leaf) is undefined/null. + */ +export function resolveArgumentPathValue( + rootValue: unknown, + path: readonly CamelCaseString[], + argumentName: CamelCaseString, + instructionName: CamelCaseString, +): unknown { + let current = rootValue; + const visited: CamelCaseString[] = []; + for (const segment of path) { + if (current === undefined || current === null) { + throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__ARGUMENT_MISSING, { + argumentName, + argumentPath: pathSuffix(visited), + instructionName, + }); + } + if (!isObjectRecord(current)) { + throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__INVARIANT_VIOLATION, { + message: `Cannot read "${segment}" from argument "${argumentName}${pathSuffix(visited)}": value is not an object.`, + }); + } + current = current[segment]; + visited.push(segment); + } + return current; +} + +function unwrapDefinedTypeLink(node: TypeNode, root: RootNode, seen: Set = new Set()): TypeNode { + if (!isNode(node, 'definedTypeLinkNode')) return node; + if (seen.has(node.name)) { + throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__INVARIANT_VIOLATION, { + message: `Circular definedTypeLinkNode chain encountered while resolving argument path through "${node.name}".`, + }); + } + seen.add(node.name); + const definedType = root.program.definedTypes.find(dt => dt.name === node.name); + if (!definedType) { + throw new CodamaError(CODAMA_ERROR__LINKED_NODE_NOT_FOUND, { + kind: 'definedTypeLinkNode', + linkNode: node, + name: node.name, + path: [], + }); + } + return unwrapDefinedTypeLink(definedType.type, root, seen); +} + +export { pathSuffix as formatArgumentPathSuffix }; diff --git a/packages/dynamic-client/test/unit/visitors/account-default-value/argumentValueNode.test.ts b/packages/dynamic-client/test/unit/visitors/account-default-value/argumentValueNode.test.ts index 8462e019c..1ab1edea5 100644 --- a/packages/dynamic-client/test/unit/visitors/account-default-value/argumentValueNode.test.ts +++ b/packages/dynamic-client/test/unit/visitors/account-default-value/argumentValueNode.test.ts @@ -1,4 +1,11 @@ -import { argumentValueNode } from 'codama'; +import { + argumentValueNode, + instructionArgumentNode, + instructionNode, + publicKeyTypeNode, + structFieldTypeNode, + structTypeNode, +} from 'codama'; import { describe, expect, test } from 'vitest'; import { SvmTestContext } from '../../../svm-test-context'; @@ -26,6 +33,48 @@ describe('account-default-value: visitArgumentValue', () => { ); }); + describe('nested struct argument paths', () => { + const ixNodeWithStructArg = instructionNode({ + arguments: [ + instructionArgumentNode({ + name: 'config', + type: structTypeNode([structFieldTypeNode({ name: 'authority', type: publicKeyTypeNode() })]), + }), + ], + name: 'testInstruction', + }); + + test('should resolve nested address from struct field', async () => { + const addr = await SvmTestContext.generateAddress(); + const visitor = makeVisitor({ + argumentsInput: { config: { authority: addr } }, + ixNode: ixNodeWithStructArg, + }); + const result = await visitor.visitArgumentValue(argumentValueNode('config', ['authority'])); + expect(result).toBe(addr); + }); + + test('should throw when intermediate struct arg is missing', async () => { + const visitor = makeVisitor({ + argumentsInput: {}, + ixNode: ixNodeWithStructArg, + }); + await expect(visitor.visitArgumentValue(argumentValueNode('config', ['authority']))).rejects.toThrow( + /Missing argument \[config\] in \[testInstruction\]/, + ); + }); + + test('should throw when leaf field is missing on provided struct', async () => { + const visitor = makeVisitor({ + argumentsInput: { config: {} }, + ixNode: ixNodeWithStructArg, + }); + await expect(visitor.visitArgumentValue(argumentValueNode('config', ['authority']))).rejects.toThrow( + /Missing argument \[config\.authority\] in \[testInstruction\]/, + ); + }); + }); + test('should throw when argument cannot be converted to Address', async () => { const visitors: ReturnType[] = [ makeVisitor({ argumentsInput: { myArg: 'not-a-valid-base58' } }), diff --git a/packages/dynamic-client/test/unit/visitors/condition-node-value/argumentValueNode.test.ts b/packages/dynamic-client/test/unit/visitors/condition-node-value/argumentValueNode.test.ts index ddb8bfaa3..f4188fb70 100644 --- a/packages/dynamic-client/test/unit/visitors/condition-node-value/argumentValueNode.test.ts +++ b/packages/dynamic-client/test/unit/visitors/condition-node-value/argumentValueNode.test.ts @@ -15,4 +15,17 @@ describe('condition-node-value: visitArgumentValue', () => { const result = await visitor.visitArgumentValue(argumentValueNode('amount')); expect(result).toBeUndefined(); }); + + test('should resolve nested struct field value via path', async () => { + const visitor = makeVisitor({ argumentsInput: { config: { threshold: 7 } } }); + const result = await visitor.visitArgumentValue(argumentValueNode('config', ['threshold'])); + expect(result).toBe(7); + }); + + test('should throw when intermediate struct arg is missing for nested path', async () => { + const visitor = makeVisitor({ argumentsInput: {} }); + await expect(visitor.visitArgumentValue(argumentValueNode('config', ['threshold']))).rejects.toThrow( + /Missing argument \[config\] in/, + ); + }); }); diff --git a/packages/dynamic-client/test/unit/visitors/pda-seed-value/argumentValueNode.test.ts b/packages/dynamic-client/test/unit/visitors/pda-seed-value/argumentValueNode.test.ts index d65ff3420..f78db8ba8 100644 --- a/packages/dynamic-client/test/unit/visitors/pda-seed-value/argumentValueNode.test.ts +++ b/packages/dynamic-client/test/unit/visitors/pda-seed-value/argumentValueNode.test.ts @@ -1,13 +1,19 @@ -import { getUtf8Codec } from '@solana/codecs'; +import { getU64Encoder, getUtf8Codec } from '@solana/codecs'; import { argumentValueNode, + definedTypeLinkNode, + definedTypeNode, instructionArgumentNode, instructionNode, numberTypeNode, + programNode, publicKeyTypeNode, remainderOptionTypeNode, + rootNode, sizePrefixTypeNode, stringTypeNode, + structFieldTypeNode, + structTypeNode, } from 'codama'; import { describe, expect, test } from 'vitest'; @@ -80,6 +86,77 @@ describe('pda-seed-value: visitArgumentValue', () => { ); }); + describe('nested struct argument paths', () => { + const planDataStruct = structTypeNode([ + structFieldTypeNode({ name: 'planId', type: numberTypeNode('u64') }), + structFieldTypeNode({ name: 'label', type: stringTypeNode('utf8') }), + ]); + const ixNodeWithStructArg = instructionNode({ + arguments: [instructionArgumentNode({ name: 'planData', type: planDataStruct })], + name: 'createPlan', + }); + + test('should encode nested numeric field via path', async () => { + const visitor = makeVisitor({ + argumentsInput: { planData: { label: 'x', planId: 42n } }, + ixNode: ixNodeWithStructArg, + }); + const result = await visitor.visitArgumentValue(argumentValueNode('planData', ['planId'])); + expect(result).toEqual(getU64Encoder().encode(42n)); + }); + + test('should throw when nested path does not exist on struct', async () => { + const visitor = makeVisitor({ + argumentsInput: { planData: { label: 'x', planId: 42n } }, + ixNode: ixNodeWithStructArg, + }); + await expect(visitor.visitArgumentValue(argumentValueNode('planData', ['missing']))).rejects.toThrow( + /struct has no field "missing"/, + ); + }); + + test('should throw when intermediate value is missing', async () => { + const visitor = makeVisitor({ + argumentsInput: {}, + ixNode: ixNodeWithStructArg, + }); + await expect(visitor.visitArgumentValue(argumentValueNode('planData', ['planId']))).rejects.toThrow( + /Missing argument \[planData\]/, + ); + }); + + test('should throw when path traverses a non-struct type', async () => { + const visitor = makeVisitor({ + argumentsInput: { title: 'hi' }, + ixNode: ixNodeWithArg, // title is stringTypeNode + }); + await expect(visitor.visitArgumentValue(argumentValueNode('title', ['bogus']))).rejects.toThrow( + /expected structTypeNode/, + ); + }); + + test('should walk through definedTypeLinkNode to nested struct field', async () => { + const planDataLink = definedTypeLinkNode('planData'); + const localProgram = programNode({ + definedTypes: [definedTypeNode({ name: 'planData', type: planDataStruct })], + name: 'test', + publicKey: '11111111111111111111111111111111', + }); + const localRoot = rootNode(localProgram); + const ixNode = instructionNode({ + arguments: [instructionArgumentNode({ name: 'planData', type: planDataLink })], + name: 'createPlan', + }); + const visitor = makeVisitor({ + argumentsInput: { planData: { label: 'x', planId: 7n } }, + ixNode, + root: localRoot, + }); + const result = await visitor.visitArgumentValue(argumentValueNode('planData', ['planId'])); + expect(result).toEqual(getU64Encoder().encode(7n)); + }); + }); + describe('remainderOptionTypeNode seeds', () => { // Mirrors the pmp IDL's metadata PDA: // the "authority" seed is remainderOptionTypeNode(publicKeyTypeNode) — null is canonical. diff --git a/packages/errors/src/context.ts b/packages/errors/src/context.ts index 252e27dda..1f5c0d38e 100644 --- a/packages/errors/src/context.ts +++ b/packages/errors/src/context.ts @@ -127,6 +127,10 @@ export type CodamaErrorContext = DefaultUnspecifiedErrorContextToUndefined<{ }; [CODAMA_ERROR__DYNAMIC_CLIENT__ARGUMENT_MISSING]: { argumentName: CamelCaseString; + // Pre-formatted dotted suffix (e.g. ".planId" or "") for nested-field references. + // Stored as a string rather than CamelCaseString[] because the message formatter is a flat + // ${value} substitution; structured data lives on ArgumentValueNode.path instead. + argumentPath?: string; instructionName: CamelCaseString; }; [CODAMA_ERROR__DYNAMIC_CLIENT__CANNOT_CONVERT_TO_ADDRESS]: { diff --git a/packages/errors/src/message-formatter.ts b/packages/errors/src/message-formatter.ts index 057c37399..c1cb13f60 100644 --- a/packages/errors/src/message-formatter.ts +++ b/packages/errors/src/message-formatter.ts @@ -12,8 +12,11 @@ export function getHumanReadableErrorMessage context: object = {}, ): string { const messageFormatString = CodamaErrorMessages[code]; - const message = messageFormatString.replace(/(? - variableName in context ? `${context[variableName as keyof typeof context] as string}` : substring, + // Missing context vars render as empty string so optional fields (e.g. argumentPath) don't + // leak `$varname` literals into messages. This is safe because callers control both the + // template and context shape via CodamaErrorContext. + const message = messageFormatString.replace(/(? + variableName in context ? `${context[variableName as keyof typeof context] as string}` : '', ); return message; } diff --git a/packages/errors/src/messages.ts b/packages/errors/src/messages.ts index 1bbe3cf45..54d5c164b 100644 --- a/packages/errors/src/messages.ts +++ b/packages/errors/src/messages.ts @@ -84,7 +84,8 @@ export const CodamaErrorMessages: Readonly<{ 'Missing account [$accountName] in [$instructionName] instruction.', [CODAMA_ERROR__DYNAMIC_CLIENT__ACCOUNT_RESOLVER_MISSING]: 'Resolver [$resolverName] not provided for account [$accountName].', - [CODAMA_ERROR__DYNAMIC_CLIENT__ARGUMENT_MISSING]: 'Missing argument [$argumentName] in [$instructionName].', + [CODAMA_ERROR__DYNAMIC_CLIENT__ARGUMENT_MISSING]: + 'Missing argument [$argumentName$argumentPath] in [$instructionName].', [CODAMA_ERROR__DYNAMIC_CLIENT__CANNOT_CONVERT_TO_ADDRESS]: 'Cannot convert value to Address: [$value].', [CODAMA_ERROR__DYNAMIC_CLIENT__CIRCULAR_ACCOUNT_DEPENDENCY]: 'Circular dependency detected: [$chain].', [CODAMA_ERROR__DYNAMIC_CLIENT__DEFAULT_VALUE_MISSING]: diff --git a/packages/node-types/src/contextualValueNodes/ArgumentValueNode.ts b/packages/node-types/src/contextualValueNodes/ArgumentValueNode.ts index b840094ea..b581dc292 100644 --- a/packages/node-types/src/contextualValueNodes/ArgumentValueNode.ts +++ b/packages/node-types/src/contextualValueNodes/ArgumentValueNode.ts @@ -5,4 +5,5 @@ export interface ArgumentValueNode { // Data. readonly name: CamelCaseString; + readonly path?: readonly CamelCaseString[]; } diff --git a/packages/nodes/src/contextualValueNodes/ArgumentValueNode.ts b/packages/nodes/src/contextualValueNodes/ArgumentValueNode.ts index 8f6ba9ea4..b8030afba 100644 --- a/packages/nodes/src/contextualValueNodes/ArgumentValueNode.ts +++ b/packages/nodes/src/contextualValueNodes/ArgumentValueNode.ts @@ -2,11 +2,18 @@ import type { ArgumentValueNode } from '@codama/node-types'; import { camelCase } from '../shared'; -export function argumentValueNode(name: string): ArgumentValueNode { +/** + * `path` segments resolve nested struct fields under the named arg + * (e.g. `argumentValueNode('plan_data', ['plan_id'])` → `planData.planId`). + * An empty or omitted path produces a node with no `path` field — empty arrays + * do not round-trip as `path: []`. + */ +export function argumentValueNode(name: string, path?: readonly string[]): ArgumentValueNode { return Object.freeze({ kind: 'argumentValueNode', // Data. name: camelCase(name), + ...(path && path.length > 0 ? { path: Object.freeze(path.map(camelCase)) } : {}), }); } diff --git a/packages/visitors-core/src/getDebugStringVisitor.ts b/packages/visitors-core/src/getDebugStringVisitor.ts index 9b971f41a..b2afe2f22 100644 --- a/packages/visitors-core/src/getDebugStringVisitor.ts +++ b/packages/visitors-core/src/getDebugStringVisitor.ts @@ -97,6 +97,10 @@ function getNodeDetails(node: Node): string[] { return [node.variant]; case 'resolverValueNode': return [node.name]; + case 'argumentValueNode': + // Mirrors `formatArgumentReference` in @codama/dynamic-client. Inlined here to avoid + // a cross-package dependency for a one-line render. + return [node.path && node.path.length > 0 ? `${node.name}.${node.path.join('.')}` : node.name]; case 'constantDiscriminatorNode': return [...(node.offset > 0 ? [`offset:${node.offset}`] : [])]; case 'fieldDiscriminatorNode': diff --git a/packages/visitors-core/src/getResolvedInstructionInputsVisitor.ts b/packages/visitors-core/src/getResolvedInstructionInputsVisitor.ts index d3f65faf2..7b76d5afc 100644 --- a/packages/visitors-core/src/getResolvedInstructionInputsVisitor.ts +++ b/packages/visitors-core/src/getResolvedInstructionInputsVisitor.ts @@ -269,7 +269,7 @@ export function getInstructionDependencies(input: InstructionInput | Instruction } if (isNode(input.defaultValue, ['argumentValueNode'])) { - return [argumentValueNode(input.defaultValue.name)]; + return [argumentValueNode(input.defaultValue.name, input.defaultValue.path)]; } if (isNode(input.defaultValue, 'pdaValueNode')) { diff --git a/packages/visitors-core/test/nodes/contextualValueNodes/ArgumentValueNode.test.ts b/packages/visitors-core/test/nodes/contextualValueNodes/ArgumentValueNode.test.ts index 4b07dd429..11296a73a 100644 --- a/packages/visitors-core/test/nodes/contextualValueNodes/ArgumentValueNode.test.ts +++ b/packages/visitors-core/test/nodes/contextualValueNodes/ArgumentValueNode.test.ts @@ -25,3 +25,11 @@ test('deleteNodesVisitor', () => { test('debugStringVisitor', () => { expectDebugStringVisitor(node, `argumentValueNode [space]`); }); + +test('debugStringVisitor with path', () => { + expectDebugStringVisitor(argumentValueNode('plan_data', ['plan_id']), `argumentValueNode [planData.planId]`); +}); + +test('identityVisitor with path', () => { + expectIdentityVisitor(argumentValueNode('plan_data', ['plan_id'])); +});