diff --git a/apps/studio/src/components/ConfigPanel.tsx b/apps/studio/src/components/ConfigPanel.tsx index 09d0eb6..2258219 100644 --- a/apps/studio/src/components/ConfigPanel.tsx +++ b/apps/studio/src/components/ConfigPanel.tsx @@ -10,9 +10,9 @@ * values) with a literal/addressRef toggle for each arg. */ -import { getContract, getStateChangingFunctions } from "../manifest/index.js"; +import { getContract, getStateChangingFunctions, getViewFunctions } from "../manifest/index.js"; import type { ManifestFunction } from "../manifest/types.js"; -import type { ContractNodeData, StudioConfigStep, StudioSetXStep, StudioGrantRoleStep, StudioConfigArg, StudioAddressRef } from "../spec/types.js"; +import type { ContractNodeData, StudioConfigStep, StudioSetXStep, StudioGrantRoleStep, StudioConfigArg, StudioAddressRef, StudioReadRef } from "../spec/types.js"; import { AddConfigCallMenu } from "./AddConfigCallMenu.js"; /** A deploy-id / contractName pair used to populate the target picker. */ @@ -106,7 +106,9 @@ function groupWriteFunctions(fns: ManifestFunction[]): { group: string; fns: Man } /** - * Render a single StudioConfigArg with a literal/addressRef toggle. + * Render a single StudioConfigArg with a literal / addressRef / read toggle + * (issue #147 adds "read": a value read from a deployed contract's view/pure + * function). */ function ConfigArgInput({ value, @@ -129,10 +131,19 @@ function ConfigArgInput({ onChange: (v: StudioConfigArg) => void; }) { const isRef = typeof value === "object" && (value as StudioAddressRef).kind === "addressRef"; + const isRead = typeof value === "object" && (value as StudioReadRef).kind === "read"; // The canonical aria-label for this arg slot (used by tests and accessibility). // Matches the original format: setx-arg-${nodeId}-${stepId}-${index} const argLabel = `setx-arg-${nodeId}-${stepId}-${index}`; + // Resolve view/pure functions available on the currently-selected read + // source contract (empty when no source is selected yet or it isn't in the + // manifest — free-text fallback never crashes). + const readContractName = isRead + ? (deployTargets.find((dt) => dt.deployId === (value as StudioReadRef).contract)?.contractName ?? "") + : ""; + const readViewFns = isRead ? getViewFunctions(readContractName) : []; + return (
{inputName && ( @@ -143,10 +154,14 @@ function ConfigArgInput({
{isRef ? ( + ) : isRead ? ( + <> + + + ) : ( void; + onChange: (v: string | StudioAddressRef | StudioReadRef) => void; }) { const isRef = typeof value === "object" && value.kind === "addressRef"; + const isRead = typeof value === "object" && value.kind === "read"; const argLabel = `config-arg-${nodeId}-${stepId}-${index}`; + const readContractName = isRead + ? (deployTargets.find((dt) => dt.deployId === (value as StudioReadRef).contract)?.contractName ?? "") + : ""; + const readViewFns = isRead ? getViewFunctions(readContractName) : []; + return (
@@ -471,13 +480,17 @@ function ConfigArgInput({ {inputName} )}
- {/* Kind toggle: literal vs addressRef */} + {/* Kind toggle: literal vs addressRef vs read */} {isRef ? ( + ) : isRead ? ( + <> + + + ) : ( void; }) { const isRef = typeof value === "object" && (value as StudioAddressRef).kind === "addressRef"; + const isRead = typeof value === "object" && (value as StudioReadRef).kind === "read"; const argLabel = `ordered-arg-${stepId}-${index}`; + const readContractName = isRead + ? (deployTargets.find((dt) => dt.deployId === (value as StudioReadRef).contract)?.contractName ?? "") + : ""; + const readViewFns = isRead ? getViewFunctions(readContractName) : []; + return (
{inputName && ( @@ -144,10 +150,14 @@ function OrderedArgInput({
{isRef ? ( + ) : isRead ? ( + <> + + + ) : ( - typeof a === "object" && - a !== null && - (a as StudioAddressRef).kind === "addressRef" && - (a as StudioAddressRef).deployId === deployId, - ); + return step.args.some((a) => { + if (typeof a !== "object" || a === null) return false; + if ((a as StudioAddressRef).kind === "addressRef") { + return (a as StudioAddressRef).deployId === deployId; + } + if ((a as StudioReadRef).kind === "read") { + const r = a as StudioReadRef; + if (r.contract === deployId) return true; + return (r.args ?? []).some( + (ra) => + typeof ra === "object" && + ra !== null && + (ra as StudioAddressRef).kind === "addressRef" && + (ra as StudioAddressRef).deployId === deployId, + ); + } + return false; + }); } return step.accountKind === "ref" && step.accountValue === deployId; } diff --git a/apps/studio/src/manifest/index.ts b/apps/studio/src/manifest/index.ts index e5a6e47..7c27661 100644 --- a/apps/studio/src/manifest/index.ts +++ b/apps/studio/src/manifest/index.ts @@ -54,3 +54,32 @@ export function getStateChangingFunctions(name: string): ManifestFunction[] { } return result; } + +/** + * Return the READ-ONLY functions (stateMutability "view" or "pure") declared + * or inherited by a contract, in manifest order, deduped by canonical + * signature (overloads are kept as distinct entries). + * + * Used by the "read" config-call arg kind (issue #147): a config-call arg can + * be filled with a value read from a deployed contract's view/pure function + * (e.g. `token.decimals()`) instead of a literal or an address ref. This + * helper lists the REAL read-only functions of the selected source contract + * so the picker doesn't offer synthetic entries. Mirrors + * getStateChangingFunctions's shape/dedup logic exactly, just inverted on + * stateMutability. Returns an empty array when the contract isn't in the + * manifest (free-text fallback) — callers should treat that as "no functions + * available" rather than crashing. + */ +export function getViewFunctions(name: string): ManifestFunction[] { + const manifest = getContract(name); + if (!manifest) return []; + const seen = new Set(); + const result: ManifestFunction[] = []; + for (const fn of manifest.functions) { + if (fn.stateMutability !== "view" && fn.stateMutability !== "pure") continue; + if (seen.has(fn.signature)) continue; + seen.add(fn.signature); + result.push(fn); + } + return result; +} diff --git a/apps/studio/src/spec/graph-to-spec.ts b/apps/studio/src/spec/graph-to-spec.ts index 8348d72..2366b4f 100644 --- a/apps/studio/src/spec/graph-to-spec.ts +++ b/apps/studio/src/spec/graph-to-spec.ts @@ -50,6 +50,7 @@ import type { ConfigSpec, ConfigStep, ConfigArg, + ReadCallArg, } from "@redeploy/config/steps"; import type { StudioEdgeData, @@ -58,6 +59,7 @@ import type { StudioOrderedConfigStep, StudioConfigArg, StudioAddressRef, + StudioReadRef, StudioParameter, } from "./types.js"; import { getContract } from "../manifest/index.js"; @@ -135,11 +137,28 @@ function parseLiteralValue(raw: string): LiteralValue { return raw; } +/** + * Normalize a "read" call's OWN positional args (StudioReadRef.args) to + * ReadArg.args. Per ReadArg's own restriction these are ref/literal ONLY — + * never a nested read — so this is a narrower version of normalizeStudioArg + * that never recurses into the "read" branch. + */ +function normalizeReadCallArg(arg: string | StudioAddressRef): ReadCallArg { + if (typeof arg === "object") { + return { kind: "ref", contract: arg.deployId }; + } + return { kind: "literal", value: parseLiteralValue(arg) }; +} + /** * Normalize a studio config arg (StudioConfigArg) to a validated ConfigArg. * * - StudioAddressRef { kind: "addressRef", deployId } * → RefArg { kind: "ref", contract: deployId } + * - StudioReadRef { kind: "read", contract, function, args? } + * → ReadArg { kind: "read", contract, function, args? } (same field + * names — passes straight through, only its OWN args are normalized + * via normalizeReadCallArg; nested reads are never emitted) * - string (literal value) * → LiteralArg { kind: "literal", value: parseLiteralValue(raw) } */ @@ -147,6 +166,16 @@ function normalizeStudioArg(arg: StudioConfigArg): ConfigArg { if (typeof arg === "object" && (arg as StudioAddressRef).kind === "addressRef") { return { kind: "ref", contract: (arg as StudioAddressRef).deployId }; } + if (typeof arg === "object" && (arg as StudioReadRef).kind === "read") { + const readArg = arg as StudioReadRef; + const args = (readArg.args ?? []).map(normalizeReadCallArg); + return { + kind: "read", + contract: readArg.contract, + function: readArg.function, + ...(args.length > 0 ? { args } : {}), + }; + } return { kind: "literal", value: parseLiteralValue(arg as string) }; } diff --git a/apps/studio/src/spec/types.ts b/apps/studio/src/spec/types.ts index 0560512..6170c10 100644 --- a/apps/studio/src/spec/types.ts +++ b/apps/studio/src/spec/types.ts @@ -22,6 +22,10 @@ * - StudioAddressRef { kind: "addressRef", deployId } — studio-facing only. * MUST be normalized to RefArg { kind: "ref", contract: deployId } before * emitting a ConfigSpec (done in graph-to-spec.ts normalizeStudioArg). + * - StudioReadRef { kind: "read", contract, function, args? } — a value read + * from a deployed contract's view/pure function (issue #147). Passes + * straight through to ReadArg { kind: "read", contract, function, args? } + * (same field names) at export time. * * ## Edge types * @@ -195,13 +199,44 @@ export interface StudioAddressRef { deployId: string; } +/** + * Studio-facing "value read from a deployed contract's view/pure function" + * arg (issue #147). Mirrors `@redeploy/config`'s `ReadArg` shape + * ({ kind: "read", contract, function, args? }) — this type exists on the + * studio side only so the canvas can carry the same field names 1:1 and + * graph-to-spec.ts's normalizeStudioArg can pass them straight through + * without any renaming. + * + * - `contract` is the deploy-id of the SOURCE contract to read FROM (picked + * from the same `deployTargets` list used by the addressRef picker). + * - `function` is the bare name of the view/pure function to call on it + * (picked via the new `getViewFunctions` manifest helper). + * - `args` are the (optional) positional args to the view call itself. v1 + * only supports no-arg view functions from the UI (e.g. `token.decimals()`) + * — the picker never populates `args` — but the field is kept so the type + * can carry them once a nested-arg editor is added. Per `ReadArg`'s own + * restriction, entries here are `string | StudioAddressRef` only — never a + * nested `StudioReadRef` (no nested reads, matching `ReadCallArg`). + */ +export interface StudioReadRef { + kind: "read"; + /** The deploy-id of the SOURCE contract to read FROM. */ + contract: string; + /** The bare name of the view/pure function to call on `contract`. */ + function: string; + /** Optional positional args to the view call (literal or addressRef only, no nested reads). */ + args?: (string | StudioAddressRef)[]; +} + /** * A config call argument as stored in the studio's in-memory state. * * - string: a literal value (parseLiteralValue interprets it to bool/number/string/null) * - StudioAddressRef: an address-of-contract reference (normalized to RefArg at export) + * - StudioReadRef: a value read from a deployed contract's view/pure function + * (normalized to ReadArg at export — see graph-to-spec.ts's normalizeStudioArg) */ -export type StudioConfigArg = string | StudioAddressRef; +export type StudioConfigArg = string | StudioAddressRef | StudioReadRef; // ---- Config step shapes (studio-internal) ---------------------------------- diff --git a/apps/studio/test/ConfigPanel.test.tsx b/apps/studio/test/ConfigPanel.test.tsx index 69fb93e..e7f5d05 100644 --- a/apps/studio/test/ConfigPanel.test.tsx +++ b/apps/studio/test/ConfigPanel.test.tsx @@ -1012,3 +1012,173 @@ describe("ConfigPanel — grantRole with ref account", () => { expect(screen.getByText("Contract ID")).not.toBeNull(); }); }); + +// --------------------------------------------------------------------------- +// setX step arg — "read" kind (issue #147): a value read from a deployed +// contract's view/pure function, selectable via the same arg-kind toggle as +// literal/addressRef. +// --------------------------------------------------------------------------- + +describe("ConfigPanel — setX step arg (read kind: issue #147)", () => { + // Vault (the attached node's contract) has no free-text fallback here — + // functionSignature "mint(address,uint256)" on Token gives us a real + // per-input arg row to toggle to "read". + const dataWithMint = makeData({ + deployId: "token", + contractName: "Token", + configSteps: [ + { + kind: "setX", + id: "step-mint", + functionName: "mint", + functionSignature: "mint(address,uint256)", + args: [], + }, + ], + }); + + it("offers a 'read' option alongside literal/address ref in the arg-kind toggle", () => { + render( + {}} + onRemoveStep={() => {}} + onUpdateSetXStep={() => {}} + onUpdateGrantRoleStep={() => {}} + />, + ); + const kindSelect = screen.getByLabelText("setx-arg-n1-step-mint-0-kind") as HTMLSelectElement; + const optionValues = Array.from(kindSelect.options).map((o) => o.value); + expect(optionValues).toEqual(["literal", "ref", "read"]); + }); + + it("switching arg kind to 'read' defaults to the first deploy target and its first view function", () => { + const onUpdateSetXStep = vi.fn(); + render( + {}} + onRemoveStep={() => {}} + onUpdateSetXStep={onUpdateSetXStep} + onUpdateGrantRoleStep={() => {}} + />, + ); + const kindSelect = screen.getByLabelText("setx-arg-n1-step-mint-0-kind"); + fireEvent.change(kindSelect, { target: { value: "read" } }); + expect(onUpdateSetXStep).toHaveBeenCalledTimes(1); + const [, , update] = onUpdateSetXStep.mock.calls[0]; + // Token's first view/pure function in manifest order is supportsInterface(bytes4). + expect(update.args[0]).toEqual({ kind: "read", contract: "token", function: "supportsInterface" }); + }); + + it("renders read-source-contract and read-function pickers once kind is 'read', and switching the view function updates the arg", () => { + const onUpdateSetXStep = vi.fn(); + const dataWithReadArg = makeData({ + deployId: "token", + contractName: "Token", + configSteps: [ + { + kind: "setX", + id: "step-mint", + functionName: "mint", + functionSignature: "mint(address,uint256)", + args: [{ kind: "read", contract: "token", function: "supportsInterface" }], + }, + ], + }); + render( + {}} + onRemoveStep={() => {}} + onUpdateSetXStep={onUpdateSetXStep} + onUpdateGrantRoleStep={() => {}} + />, + ); + const contractSelect = screen.getByLabelText("setx-arg-n1-step-mint-0-read-contract") as HTMLSelectElement; + expect(contractSelect.value).toBe("token"); + const fnSelect = screen.getByLabelText("setx-arg-n1-step-mint-0-read-function") as HTMLSelectElement; + expect(fnSelect.value).toBe("supportsInterface"); + expect(fnSelect.innerHTML).toContain("decimals()"); + + fireEvent.change(fnSelect, { target: { value: "decimals" } }); + expect(onUpdateSetXStep).toHaveBeenCalledWith("n1", "step-mint", { + args: [{ kind: "read", contract: "token", function: "decimals" }], + }); + }); + + it("changing the read source contract re-picks the first view function of the new contract", () => { + const onUpdateSetXStep = vi.fn(); + // Vault.setRegistry(address) — a real manifest function with one input, + // so the per-input ConfigArgInput (not the free-text fallback) renders. + const dataWithReadArg = makeData({ + deployId: "vault", + contractName: "Vault", + configSteps: [ + { + kind: "setX", + id: "step-x", + functionName: "setRegistry", + functionSignature: "setRegistry(address)", + args: [{ kind: "read", contract: "token", function: "decimals" }], + }, + ], + }); + render( + {}} + onRemoveStep={() => {}} + onUpdateSetXStep={onUpdateSetXStep} + onUpdateGrantRoleStep={() => {}} + />, + ); + const contractSelect = screen.getByLabelText("setx-arg-n1-step-x-0-read-contract") as HTMLSelectElement; + fireEvent.change(contractSelect, { target: { value: "vault" } }); + expect(onUpdateSetXStep).toHaveBeenCalledTimes(1); + const [, , update] = onUpdateSetXStep.mock.calls[0]; + // Vault's first view/pure function in manifest order is balanceOf(address). + expect(update.args[0]).toEqual({ kind: "read", contract: "vault", function: "balanceOf" }); + }); + + it("renders the '— no view functions —' fallback option when the read source contract has none in the manifest", () => { + // "Widget" is not in the generated manifest at all, so getViewFunctions + // resolves to an empty array (free-text-fallback contract, never crashes). + const WIDGET_TARGET: DeployTarget = { deployId: "widget", contractName: "Widget" }; + const dataWithReadArg = makeData({ + deployId: "token", + contractName: "Token", + configSteps: [ + { + kind: "setX", + id: "step-mint", + functionName: "mint", + functionSignature: "mint(address,uint256)", + args: [{ kind: "read", contract: "widget", function: "" }], + }, + ], + }); + render( + {}} + onRemoveStep={() => {}} + onUpdateSetXStep={() => {}} + onUpdateGrantRoleStep={() => {}} + />, + ); + const fnSelect = screen.getByLabelText("setx-arg-n1-step-mint-0-read-function") as HTMLSelectElement; + expect(fnSelect.options.length).toBe(1); + expect(fnSelect.options[0].textContent).toBe("— no view functions —"); + }); +}); diff --git a/apps/studio/test/config-panel-ordered.test.tsx b/apps/studio/test/config-panel-ordered.test.tsx index 669b13a..2708616 100644 --- a/apps/studio/test/config-panel-ordered.test.tsx +++ b/apps/studio/test/config-panel-ordered.test.tsx @@ -23,7 +23,7 @@ import { useGraph } from "../src/hooks/useGraph.js"; import { validateConfig } from "@redeploy/config"; import { validateSpec } from "@redeploy/core"; import type { GraphNode } from "../src/spec/graph-to-spec.js"; -import type { ContractNodeData, StudioOrderedConfigStep, StudioAddressRef } from "../src/spec/types.js"; +import type { ContractNodeData, StudioOrderedConfigStep, StudioAddressRef, StudioReadRef, StudioSetXStep } from "../src/spec/types.js"; // --------------------------------------------------------------------------- // Helpers @@ -394,6 +394,119 @@ describe("per-node config section — ContractNode with configCallbacks", () => }); }); +// --------------------------------------------------------------------------- +// 2b. ContractNode's ConfigArgInput — "read" arg kind (issue #147) +// --------------------------------------------------------------------------- + +describe("per-node config section — ConfigArgInput 'read' kind (issue #147)", () => { + // Token.mint(address,uint256) — a real manifest function with two inputs, + // so the per-input ConfigArgInput (not the free-text fallback) renders. + function makeMintStep(args: StudioSetXStep["args"] = []): ContractNodeData["configSteps"][number] { + return { + kind: "setX", + id: "step-mint", + functionName: "mint", + functionSignature: "mint(address,uint256)", + args, + }; + } + + it("offers a 'read' option alongside literal/address ref in the arg-kind toggle", () => { + const configCallbacks = { + onAddConfigStep: noop, + onRemoveConfigStep: noop, + onUpdateSetXStep: noop, + onUpdateGrantRoleStep: noop, + deployTargets: [{ deployId: "token", contractName: "Token" }], + }; + const data = { ...makeContractNodeData({ configSteps: [makeMintStep()] }), configCallbacks }; + renderContractNode(data as unknown as ContractNodeData, "n1"); + const kindSelect = screen.getByLabelText("config-arg-n1-step-mint-0-kind") as HTMLSelectElement; + const optionValues = Array.from(kindSelect.options).map((o) => o.value); + expect(optionValues).toEqual(["literal", "ref", "read"]); + }); + + it("switching arg kind to 'read' defaults to the first deploy target and its first view function, calling onUpdateSetXStep", () => { + const updates: Array<{ nodeId: string; stepId: string; update: { args: unknown[] } }> = []; + const configCallbacks = { + onAddConfigStep: noop, + onRemoveConfigStep: noop, + onUpdateSetXStep: (nodeId: string, stepId: string, update: { args: unknown[] }) => + updates.push({ nodeId, stepId, update }), + onUpdateGrantRoleStep: noop, + deployTargets: [{ deployId: "token", contractName: "Token" }], + }; + const data = { ...makeContractNodeData({ configSteps: [makeMintStep()] }), configCallbacks }; + renderContractNode(data as unknown as ContractNodeData, "n1"); + const kindSelect = screen.getByLabelText("config-arg-n1-step-mint-0-kind"); + fireEvent.change(kindSelect, { target: { value: "read" } }); + expect(updates).toHaveLength(1); + expect(updates[0].nodeId).toBe("n1"); + expect(updates[0].stepId).toBe("step-mint"); + // Token's first view/pure function in manifest order is supportsInterface(bytes4). + expect(updates[0].update.args[0]).toEqual({ kind: "read", contract: "token", function: "supportsInterface" }); + }); + + it("renders read-source-contract and read-function pickers once kind is 'read', listing deployTargets and the source's view functions", () => { + const step = makeMintStep([{ kind: "read", contract: "token", function: "decimals" }]); + const configCallbacks = { + onAddConfigStep: noop, + onRemoveConfigStep: noop, + onUpdateSetXStep: noop, + onUpdateGrantRoleStep: noop, + deployTargets: [{ deployId: "token", contractName: "Token" }], + }; + const data = { ...makeContractNodeData({ configSteps: [step] }), configCallbacks }; + renderContractNode(data as unknown as ContractNodeData, "n1"); + const contractSelect = screen.getByLabelText("config-arg-n1-step-mint-0-read-contract") as HTMLSelectElement; + expect(contractSelect.value).toBe("token"); + expect(contractSelect.innerHTML).toContain("token"); + const fnSelect = screen.getByLabelText("config-arg-n1-step-mint-0-read-function") as HTMLSelectElement; + expect(fnSelect.value).toBe("decimals"); + expect(fnSelect.innerHTML).toContain("decimals()"); + }); + + it("switching the read view function calls onUpdateSetXStep with the updated function name", () => { + const updates: Array<{ update: { args: unknown[] } }> = []; + const step = makeMintStep([{ kind: "read", contract: "token", function: "decimals" }]); + const configCallbacks = { + onAddConfigStep: noop, + onRemoveConfigStep: noop, + onUpdateSetXStep: (_nodeId: string, _stepId: string, update: { args: unknown[] }) => updates.push({ update }), + onUpdateGrantRoleStep: noop, + deployTargets: [{ deployId: "token", contractName: "Token" }], + }; + const data = { ...makeContractNodeData({ configSteps: [step] }), configCallbacks }; + renderContractNode(data as unknown as ContractNodeData, "n1"); + const fnSelect = screen.getByLabelText("config-arg-n1-step-mint-0-read-function"); + fireEvent.change(fnSelect, { target: { value: "totalSupply" } }); + expect(updates).toHaveLength(1); + expect(updates[0].update.args[0]).toEqual({ kind: "read", contract: "token", function: "totalSupply" }); + }); + + it("changing the read source contract re-picks the first view function of the new contract (parity w/ ConfigPanel)", () => { + const updates: Array<{ update: { args: unknown[] } }> = []; + const step = makeMintStep([{ kind: "read", contract: "token", function: "decimals" }]); + const configCallbacks = { + onAddConfigStep: noop, + onRemoveConfigStep: noop, + onUpdateSetXStep: (_nodeId: string, _stepId: string, update: { args: unknown[] }) => updates.push({ update }), + onUpdateGrantRoleStep: noop, + deployTargets: [ + { deployId: "token", contractName: "Token" }, + { deployId: "vault", contractName: "Vault" }, + ], + }; + const data = { ...makeContractNodeData({ configSteps: [step] }), configCallbacks }; + renderContractNode(data as unknown as ContractNodeData, "n1"); + const contractSelect = screen.getByLabelText("config-arg-n1-step-mint-0-read-contract"); + fireEvent.change(contractSelect, { target: { value: "vault" } }); + expect(updates).toHaveLength(1); + // Vault's first view/pure function in manifest order is balanceOf(address). + expect(updates[0].update.args[0]).toEqual({ kind: "read", contract: "vault", function: "balanceOf" }); + }); +}); + // --------------------------------------------------------------------------- // 3. OrderedConfigPanel — add/reorder/remove // --------------------------------------------------------------------------- @@ -797,6 +910,112 @@ describe("graphToSpec — address ref normalization", () => { }); }); +// --------------------------------------------------------------------------- +// 5a-bis. graphToSpec — "read" arg normalization (issue #147) +// --------------------------------------------------------------------------- + +describe("graphToSpec — read arg normalization (issue #147)", () => { + it("normalizes a no-arg StudioReadRef to a ReadArg in per-node setX step args", () => { + const readRef: StudioReadRef = { kind: "read", contract: "token", function: "decimals" }; + const node = makeGraphNode("n1", "vault", "Vault", { + configSteps: [ + { kind: "setX", id: "s1", functionName: "setDecimals", args: [readRef] }, + ], + }); + const { config } = graphToSpec([node], []); + const step = config.steps[0] as { kind: "setX"; args?: Array<{ kind: string; contract: string; function: string }> }; + expect(step.args).toHaveLength(1); + expect(step.args![0]).toEqual({ kind: "read", contract: "token", function: "decimals" }); + }); + + it("normalizes a StudioReadRef in ordered step args", () => { + const readRef: StudioReadRef = { kind: "read", contract: "token", function: "decimals" }; + const node = makeGraphNode("n1", "token", "Token"); + const orderedSteps: StudioOrderedConfigStep[] = [ + { + kind: "setX", + id: "os1", + functionName: "setDecimals", + target: "token", + args: [readRef], + }, + ]; + const { config } = graphToSpec([node], [], orderedSteps); + const ordStep = config.orderedSteps![0] as { kind: "setX"; args?: Array<{ kind: string; contract: string; function: string }> }; + expect(ordStep.args).toHaveLength(1); + expect(ordStep.args![0]).toEqual({ kind: "read", contract: "token", function: "decimals" }); + }); + + it("normalizes a StudioReadRef with its own literal/addressRef args (ReadCallArg)", () => { + const readRef: StudioReadRef = { + kind: "read", + contract: "registry", + function: "lookup", + args: ["minter", { kind: "addressRef", deployId: "token" }], + }; + const node = makeGraphNode("n1", "vault", "Vault", { + configSteps: [ + { kind: "setX", id: "s1", functionName: "setLookup", args: [readRef] }, + ], + }); + const { config } = graphToSpec([node], []); + const step = config.steps[0] as { + kind: "setX"; + args?: Array<{ kind: string; contract: string; function: string; args?: unknown[] }>; + }; + expect(step.args![0]).toEqual({ + kind: "read", + contract: "registry", + function: "lookup", + args: [ + { kind: "literal", value: "minter" }, + { kind: "ref", contract: "token" }, + ], + }); + }); + + it("mixed args: StudioReadRef + literal + addressRef are all normalized correctly", () => { + const readRef: StudioReadRef = { kind: "read", contract: "token", function: "decimals" }; + const addressRef: StudioAddressRef = { kind: "addressRef", deployId: "registry" }; + const node = makeGraphNode("n1", "vault", "Vault", { + configSteps: [ + { kind: "setX", id: "s1", functionName: "initialize", args: [readRef, "100", addressRef] }, + ], + }); + const { config } = graphToSpec([node], []); + const step = config.steps[0] as { kind: "setX"; args?: Array> }; + expect(step.args).toHaveLength(3); + expect(step.args![0]).toEqual({ kind: "read", contract: "token", function: "decimals" }); + expect(step.args![1]).toEqual({ kind: "literal", value: 100 }); + expect(step.args![2]).toEqual({ kind: "ref", contract: "registry" }); + }); + + it("emitted ReadArg has exactly the ReadArg fields — no leftover studio-only keys (e.g. no empty 'args' when the read call has no args of its own)", () => { + const readRef: StudioReadRef = { kind: "read", contract: "token", function: "decimals" }; + const node = makeGraphNode("n1", "vault", "Vault", { + configSteps: [ + { kind: "setX", id: "s1", functionName: "setDecimals", args: [readRef] }, + ], + }); + const { config } = graphToSpec([node], []); + const step = config.steps[0] as { kind: "setX"; args?: Array> }; + expect(Object.keys(step.args![0]).sort()).toEqual(["contract", "function", "kind"]); + }); + + it("produces a valid ConfigSpec after read-arg normalization", () => { + const readRef: StudioReadRef = { kind: "read", contract: "token", function: "decimals" }; + const token = makeGraphNode("n1", "token", "Token"); + const vault = makeGraphNode("n2", "vault", "Vault", { + configSteps: [ + { kind: "setX", id: "s1", functionName: "setFeeBps", args: [readRef] }, + ], + }); + const { deployment, config } = graphToSpec([token, vault], []); + expect(validateSpec(deployment).ok).toBe(true); + expect(validateConfig(config, deployment).ok).toBe(true); + }); +}); + // --------------------------------------------------------------------------- // 5b. OrderedArgInput — literal/addressRef toggle in OrderedStepCard // @@ -914,6 +1133,92 @@ describe("OrderedConfigPanel — OrderedArgInput literal/addressRef toggle", () }); }); +// --------------------------------------------------------------------------- +// 5b-bis. OrderedArgInput — "read" arg kind (issue #147) +// --------------------------------------------------------------------------- + +describe("OrderedConfigPanel — OrderedArgInput 'read' kind (issue #147)", () => { + function makeRegistryStep(id: string, args: StudioOrderedConfigStep["args"] = ["myKey"]): StudioOrderedConfigStep { + return { + kind: "setX", + id, + functionName: "register", + functionSignature: "register(string,address)", + target: "reg", + args, + }; + } + + const deployTargets = [ + { deployId: "reg", contractName: "Registry" }, + { deployId: "token", contractName: "Token" }, + ]; + + it("offers a 'read' option alongside literal/address ref in the arg-kind toggle", () => { + render( + + ); + const kindSelect = screen.getByRole("combobox", { name: "ordered-arg-s1-0-kind" }) as HTMLSelectElement; + const optionValues = Array.from(kindSelect.options).map((o) => o.value); + expect(optionValues).toEqual(["literal", "ref", "read"]); + }); + + it("switching arg kind to 'read' defaults to the first deploy target and its first view function", () => { + const updates: Array<{ stepId: string; update: { args: unknown[] } }> = []; + render( + updates.push({ stepId, update: update as { args: unknown[] } })} + onMoveUp={noop} + onMoveDown={noop} + /> + ); + const kindSelect = screen.getByRole("combobox", { name: "ordered-arg-s1-0-kind" }); + fireEvent.change(kindSelect, { target: { value: "read" } }); + expect(updates).toHaveLength(1); + // deployTargets[0] is "reg" (Registry); Registry's first view/pure function + // in manifest order is lookup(string), used as the default. + const newArgs = updates[0].update.args; + expect(newArgs[0]).toEqual({ kind: "read", contract: "reg", function: "lookup" }); + }); + + it("renders read-source-contract and read-function pickers once kind is 'read'; switching the view function calls onUpdateStep", () => { + const readRef: StudioReadRef = { kind: "read", contract: "token", function: "decimals" }; + const updates: Array<{ stepId: string; update: { args: unknown[] } }> = []; + render( + updates.push({ stepId, update: update as { args: unknown[] } })} + onMoveUp={noop} + onMoveDown={noop} + /> + ); + const contractSelect = screen.getByRole("combobox", { name: "ordered-arg-s1-0-read-contract" }) as HTMLSelectElement; + expect(contractSelect.value).toBe("token"); + const fnSelect = screen.getByRole("combobox", { name: "ordered-arg-s1-0-read-function" }) as HTMLSelectElement; + expect(fnSelect.value).toBe("decimals"); + expect(fnSelect.innerHTML).toContain("decimals()"); + + fireEvent.change(fnSelect, { target: { value: "balanceOf" } }); + expect(updates).toHaveLength(1); + expect(updates[0].update.args[0]).toEqual({ kind: "read", contract: "token", function: "balanceOf" }); + }); +}); + // --------------------------------------------------------------------------- // 5c. graphToSpec — orderedSteps with validateConfig + order preservation // --------------------------------------------------------------------------- diff --git a/apps/studio/test/manifest-dedup.test.ts b/apps/studio/test/manifest-dedup.test.ts new file mode 100644 index 0000000..f8961f4 --- /dev/null +++ b/apps/studio/test/manifest-dedup.test.ts @@ -0,0 +1,68 @@ +/** + * manifest-dedup.test.ts + * + * Issue #147 tests-lens finding #5 ("dedup honesty"): manifest.test.ts's + * "returned functions have no duplicate signatures" tests for + * getStateChangingFunctions/getViewFunctions only run against the REAL + * generated manifest (contracts.generated.json), whose entries are already + * deduped upstream by deriveManifest's own most-derived-wins `seenSignatures` + * pass (src/manifest/derive.ts). No project fixture has two functions in the + * SAME contract's final `.functions` array sharing a signature, so those + * tests never actually exercise getStateChangingFunctions/getViewFunctions's + * own `if (seen.has(fn.signature)) continue;` dedup line — they pass + * trivially regardless of whether that line exists. + * + * This file mocks contracts.generated.json with a synthetic contract whose + * `functions` array contains a genuine duplicate signature (something that + * can never come out of deriveManifests, but IS a legal ContractManifest + * shape per the type, and is exactly the shape getStateChangingFunctions/ + * getViewFunctions must defend against), and asserts the collision is + * actually skipped and the FIRST occurrence wins. + */ + +import { describe, it, expect, vi } from "vitest"; +import type { ContractManifest } from "../src/manifest/types.js"; + +const DUP_FIXTURE: ContractManifest[] = [ + { + name: "DupFixture", + sourcePath: "src/DupFixture.sol", + packageSegments: ["src"], + constructorArgs: [], + inheritance: ["DupFixture", "Base"], + functions: [ + // Two entries sharing "foo()" — a genuine duplicate VIEW signature. + { name: "foo", signature: "foo()", declaredIn: "Base", inputs: [], stateMutability: "view" }, + { name: "foo", signature: "foo()", declaredIn: "DupFixture", inputs: [], stateMutability: "view" }, + // Two entries sharing "bar()" — a genuine duplicate STATE-CHANGING signature. + { name: "bar", signature: "bar()", declaredIn: "DupFixture", inputs: [], stateMutability: "nonpayable" }, + { name: "bar", signature: "bar()", declaredIn: "Base", inputs: [], stateMutability: "nonpayable" }, + ], + }, +]; + +vi.mock("../src/manifest/contracts.generated.json", () => ({ + default: DUP_FIXTURE, +})); + +const { getViewFunctions, getStateChangingFunctions } = await import("../src/manifest/index.js"); + +describe("getViewFunctions / getStateChangingFunctions — genuine duplicate-signature collision (issue #147 finding #5)", () => { + it("getViewFunctions collapses a genuine duplicate view-function signature to ONE entry, keeping the first occurrence", () => { + const fns = getViewFunctions("DupFixture"); + expect(fns).toHaveLength(1); + expect(fns[0]).toEqual({ name: "foo", signature: "foo()", declaredIn: "Base", inputs: [], stateMutability: "view" }); + }); + + it("getStateChangingFunctions collapses a genuine duplicate state-changing-function signature to ONE entry, keeping the first occurrence", () => { + const fns = getStateChangingFunctions("DupFixture"); + expect(fns).toHaveLength(1); + expect(fns[0]).toEqual({ + name: "bar", + signature: "bar()", + declaredIn: "DupFixture", + inputs: [], + stateMutability: "nonpayable", + }); + }); +}); diff --git a/apps/studio/test/manifest.test.ts b/apps/studio/test/manifest.test.ts index a52d4c9..33b801d 100644 --- a/apps/studio/test/manifest.test.ts +++ b/apps/studio/test/manifest.test.ts @@ -8,7 +8,7 @@ import { describe, it, expect } from "vitest"; import fixtureOutputs from "./fixtures/contracts-build-info.json"; -import { contractManifest, getContract, getStateChangingFunctions } from "../src/manifest/index.js"; +import { contractManifest, getContract, getStateChangingFunctions, getViewFunctions } from "../src/manifest/index.js"; import type { ContractManifest } from "../src/manifest/types.js"; import { deriveManifests, derivePackageSegments } from "../src/manifest/derive.js"; import type { FoundryContractOutput } from "../src/manifest/derive.js"; @@ -334,6 +334,62 @@ describe("getStateChangingFunctions", () => { }); }); +// --------------------------------------------------------------------------- +// getViewFunctions — issue #147: the "read" config-call arg kind's view/pure +// function picker lists a contract's REAL read-only functions. +// --------------------------------------------------------------------------- + +describe("getViewFunctions", () => { + it("returns an empty array for a contract not in the manifest (free-text fallback never crashes)", () => { + expect(getViewFunctions("NonExistentContract123")).toEqual([]); + }); + + it("Token: includes real view/pure functions (name, symbol, decimals, totalSupply, balanceOf, allowance, hasRole, getRoleAdmin, supportsInterface)", () => { + const names = getViewFunctions("Token").map((f) => f.name); + for (const expected of ["name", "symbol", "decimals", "totalSupply", "balanceOf", "allowance", "hasRole", "getRoleAdmin", "supportsInterface"]) { + expect(names).toContain(expected); + } + }); + + it("Token: excludes state-changing functions (mint, grantRole, revokeRole, renounceRole, transfer, approve, transferFrom)", () => { + const names = getViewFunctions("Token").map((f) => f.name); + for (const writeFn of ["mint", "grantRole", "revokeRole", "renounceRole", "transfer", "approve", "transferFrom"]) { + expect(names).not.toContain(writeFn); + } + }); + + it("Token: decimals() is a no-arg view function", () => { + const decimals = getViewFunctions("Token").find((f) => f.name === "decimals"); + expect(decimals).toBeDefined(); + expect(decimals!.stateMutability).toBe("view"); + expect(decimals!.inputs).toEqual([]); + }); + + it("returned functions have no duplicate signatures", () => { + for (const name of ["Token", "Vault", "Registry", "VaultERC4626"]) { + const sigs = getViewFunctions(name).map((f) => f.signature); + expect(sigs.length).toBe(new Set(sigs).size); + } + }); + + it("preserves the manifest's declared order", () => { + const manifestOrder = getContract("Token")! + .functions.filter((f) => f.stateMutability === "view" || f.stateMutability === "pure") + .map((f) => f.signature); + expect(getViewFunctions("Token").map((f) => f.signature)).toEqual(manifestOrder); + }); + + it("getStateChangingFunctions and getViewFunctions partition a contract's functions with no overlap", () => { + for (const name of ["Token", "Vault", "Registry", "VaultERC4626", "Overloaded"]) { + const writeSigs = new Set(getStateChangingFunctions(name).map((f) => f.signature)); + const viewSigs = new Set(getViewFunctions(name).map((f) => f.signature)); + for (const sig of viewSigs) { + expect(writeSigs.has(sig)).toBe(false); + } + } + }); +}); + // --------------------------------------------------------------------------- // Pure derivation tests (against fixture build-info, independent of forge) // --------------------------------------------------------------------------- diff --git a/apps/studio/test/useGraph.test.ts b/apps/studio/test/useGraph.test.ts index 6567a0c..7898838 100644 --- a/apps/studio/test/useGraph.test.ts +++ b/apps/studio/test/useGraph.test.ts @@ -705,6 +705,36 @@ describe("stepReferencesDeployId", () => { expect(stepReferencesDeployId(setXStep, "")).toBe(false); expect(stepReferencesDeployId(grantRoleStep, "")).toBe(false); }); + + it("matches a setX step with a read arg whose source contract equals the deployId (issue #147)", () => { + const step = { + kind: "setX" as const, + id: "s1", + functionName: "setFee", + args: [{ kind: "read" as const, contract: "token", function: "decimals" }], + }; + expect(stepReferencesDeployId(step, "token")).toBe(true); + expect(stepReferencesDeployId(step, "other")).toBe(false); + }); + + it("matches a setX step with a read arg whose nested addressRef call-arg equals the deployId (issue #147)", () => { + const step = { + kind: "setX" as const, + id: "s1", + functionName: "setFee", + args: [ + { + kind: "read" as const, + contract: "token", + function: "allowance", + args: [{ kind: "addressRef" as const, deployId: "oracle" }], + }, + ], + }; + expect(stepReferencesDeployId(step, "oracle")).toBe(true); + expect(stepReferencesDeployId(step, "token")).toBe(true); + expect(stepReferencesDeployId(step, "other")).toBe(false); + }); }); // --------------------------------------------------------------------------- @@ -823,6 +853,30 @@ describe("useGraph — node deletion (onNodesChange remove) cleans up dangling r expect(nd(result.current.nodes[0]).configSteps).toHaveLength(0); }); + it("removes a read-arg step (setX) on a surviving node referencing the deleted contract's deployId (issue #147)", () => { + const { result } = renderHook(() => useGraph()); + act(() => { + result.current.addContractFromManifest(REGISTRY_MANIFEST); + result.current.addContractFromManifest(REGISTRY_MANIFEST); + }); + const [n1, n2] = result.current.nodes; + + act(() => nd(result.current.nodes[0]).onUpdateDeployId(n1.id, "registryA")); + act(() => result.current.addConfigStep(n2.id, A_WRITE_FN)); + const stepId = nd(result.current.nodes[1]).configSteps[0].id; + act(() => + result.current.updateSetXStep(n2.id, stepId, { + functionName: "setRegistry", + args: [{ kind: "read", contract: "registryA", function: "owner" }], + }), + ); + + act(() => result.current.onNodesChange([{ id: n1.id, type: "remove" }])); + + expect(result.current.nodes).toHaveLength(1); + expect(nd(result.current.nodes[0]).configSteps).toHaveLength(0); + }); + it("removes a global ordered step referencing the deleted contract's deployId", () => { const { result } = renderHook(() => useGraph()); act(() => result.current.addContractFromManifest(REGISTRY_MANIFEST));