fix: close selected-step dataflow review findings

This commit is contained in:
lda
2026-08-10 02:26:33 +07:00 Verified
parent 070cbe6d7e
commit 167f080dd6
12 changed files with 286 additions and 31 deletions
@@ -59,7 +59,7 @@ describe("CapabilitySetupForm", () => {
expect(submissions).toEqual([]);
});
it("rejects timeout zero and accepts a positive timeout", async () => {
it("requires timeout to be a positive integer", async () => {
const user = userEvent.setup();
const submissions: unknown[] = [];
render(<CapabilitySetupForm onSubmit={(value) => { submissions.push(value); }} />);
@@ -72,8 +72,15 @@ describe("CapabilitySetupForm", () => {
await user.clear(screen.getByRole("spinbutton", { name: "Timeout seconds" }));
await user.type(screen.getByRole("spinbutton", { name: "Timeout seconds" }), "2.5");
await user.click(screen.getByRole("button", { name: "Save setup" }));
expect(screen.getByRole("alert")).toHaveTextContent("Timeout must be a whole number greater than 0.");
expect(submissions).toEqual([]);
expect(submissions).toEqual([{ timeoutSeconds: 2.5 }]);
await user.clear(screen.getByRole("spinbutton", { name: "Timeout seconds" }));
await user.type(screen.getByRole("spinbutton", { name: "Timeout seconds" }), "2");
await user.click(screen.getByRole("button", { name: "Save setup" }));
expect(submissions).toEqual([{ timeoutSeconds: 2 }]);
expect(screen.getByRole("spinbutton", { name: "Timeout seconds" })).toHaveAttribute("step", "1");
expect(screen.getByRole("spinbutton", { name: "Timeout seconds" })).toHaveAttribute("inputmode", "numeric");
});
it("associates local retry errors with the retry control and avoids duplicate ids", async () => {
@@ -105,6 +105,8 @@ export const CapabilitySetupForm = ({
const parsed = Number(timeoutSeconds);
if (!Number.isFinite(parsed) || parsed <= 0) {
nextIssues.timeoutSeconds = "Timeout must be greater than 0.";
} else if (!Number.isInteger(parsed)) {
nextIssues.timeoutSeconds = "Timeout must be a whole number greater than 0.";
} else {
patch.timeoutSeconds = parsed;
}
@@ -160,10 +162,10 @@ export const CapabilitySetupForm = ({
aria-label="Timeout seconds"
aria-invalid={diagnosticFor("timeoutSeconds") !== null}
id={controlId("timeoutSeconds")}
inputMode="decimal"
min="0.000001"
inputMode="numeric"
min={1}
onChange={(event) => { touch("timeoutSeconds"); setTimeoutSeconds(event.target.value); }}
step="any"
step={1}
type="number"
value={timeoutSeconds}
/>
@@ -103,8 +103,8 @@ describe("ContextInspector", () => {
expect(screen.getByRole("spinbutton", { name: "Retry" })).toHaveValue(2);
expect(screen.getByRole("spinbutton", { name: "Timeout seconds" })).toHaveValue(45);
fireEvent.click(screen.getByRole("tab", { name: "Inputs" }));
expect(screen.getByRole("textbox", { name: "Target for row 1" })).toHaveValue("title");
expect(screen.getByRole("textbox", { name: "Source path for input row 2" })).toHaveValue("input.count");
expect(screen.getByRole("combobox", { name: "Target for row 1" })).toHaveValue("title");
expect(screen.getByRole("combobox", { name: "Source path for input row 2" })).toHaveValue("input.count");
});
it("keeps deferred actions focusable while keyboard activation does not dispatch", async () => {
@@ -187,7 +187,7 @@ describe("ContextInspector", () => {
expect(screen.getAllByText("Title is not accepted.")).not.toHaveLength(0);
expect(screen.getAllByText("Retry must be non-negative.")).not.toHaveLength(0);
expect(screen.getAllByText("Destination field is not declared.")).not.toHaveLength(0);
expect(screen.getByRole("textbox", { name: "Target for row 1" })).toHaveAttribute(
expect(screen.getByRole("combobox", { name: "Target for row 1" })).toHaveAttribute(
"aria-invalid",
"true",
);
@@ -246,7 +246,7 @@ describe("DraftWorkbench", () => {
within(inspector).getByRole("button", { name: "Add input row", hidden: true }),
);
await user.type(
within(inspector).getByRole("textbox", { name: "Target for row 1", hidden: true }),
within(inspector).getByRole("combobox", { name: "Target for row 1", hidden: true }),
"title",
);
await user.click(
@@ -263,7 +263,7 @@ describe("DraftWorkbench", () => {
within(inspector).getByRole("tab", { name: "Outputs" }),
).toHaveAttribute("aria-selected", "true");
expect(
within(inspector).getByRole("textbox", { name: "Target for row 1", hidden: true }),
within(inspector).getByRole("combobox", { name: "Target for row 1", hidden: true }),
).toHaveValue("title");
});
@@ -135,6 +135,39 @@ describe("SelectedCapabilityInspector", () => {
expect(controller.updateSetup).toHaveBeenCalledWith({});
});
it("keeps explicit null and singleton binding containers visible as unsupported rows", async () => {
const user = userEvent.setup();
const workspace = draft(
"read",
null,
{ source: "text", target: "state.existing" },
);
const controller = controllerFor(workspace);
render(
<SelectedCapabilityInspector
capabilityDetail={detail}
capabilityDetailMessage={null}
capabilityDetailPhase="ready"
controller={controller}
draft={workspace}
nodeKind="use"
nodeRef="demo.read"
stepId="read"
/>,
);
await user.click(screen.getByRole("tab", { name: "Inputs" }));
expect(screen.getByRole("region", { name: "Raw unsupported input row 1" })).toHaveTextContent("null");
await user.click(screen.getByRole("button", { name: "Save inputs" }));
expect(controller.setStepInputs).not.toHaveBeenCalled();
await user.click(screen.getByRole("tab", { name: "Outputs" }));
expect(screen.getByRole("region", { name: "Raw unsupported output row 1" })).toHaveTextContent("text");
await user.click(screen.getByRole("button", { name: "Save outputs" }));
expect(controller.setStepOutputs).not.toHaveBeenCalled();
});
it("keeps diagnostic ids unique across failing setup and hidden binding forms", async () => {
const user = userEvent.setup();
const workspace = draft(
@@ -162,7 +195,7 @@ describe("SelectedCapabilityInspector", () => {
});
await user.click(screen.getByRole("button", { name: "Save setup" }));
await user.click(screen.getByRole("tab", { name: "Inputs" }));
await user.clear(screen.getByRole("textbox", { name: "Target for row 1" }));
await user.clear(screen.getByRole("combobox", { name: "Target for row 1" }));
await user.click(screen.getByRole("button", { name: "Save inputs" }));
await user.click(screen.getByRole("tab", { name: "Outputs" }));
await user.clear(screen.getByRole("combobox", { name: "Target for output row 1" }));
@@ -192,7 +225,7 @@ describe("SelectedCapabilityInspector", () => {
);
await userEvent.setup().click(screen.getByRole("tab", { name: "Inputs" }));
expect(screen.getByRole("textbox", { name: "Target for row 1" })).toHaveValue("title");
expect(screen.getByRole("combobox", { name: "Target for row 1" })).toHaveValue("title");
rerender(
<SelectedCapabilityInspector
capabilityDetail={detail}
@@ -207,7 +240,7 @@ describe("SelectedCapabilityInspector", () => {
/>,
);
await userEvent.setup().click(screen.getByRole("tab", { name: "Inputs" }));
expect(screen.getByRole("textbox", { name: "Target for row 1" })).toHaveValue("title");
expect(screen.getByRole("combobox", { name: "Target for row 1" })).toHaveValue("title");
expect(screen.getByRole("textbox", { name: "Title" })).toHaveValue("Second");
});
@@ -105,7 +105,9 @@ export const SelectedCapabilityInspector = ({
? controller.preservedCapabilityForm.input
: null;
// Forms receive raw-row projections so malformed persisted entries stay in order.
const inputRows = inputBindingRows(rawStep?.input ?? preservedForm?.inputBindings);
const inputRows = inputBindingRows(
rawStep?.input !== undefined ? rawStep.input : preservedForm?.inputBindings,
);
const outputRows = outputBindingRows(rawStep?.output);
const inputDiagnostics = bindingDiagnosticsForStep(draft.diagnostics, stepId, "input", projected?.compiledNodeIndex ?? null);
const outputDiagnostics = bindingDiagnosticsForStep(draft.diagnostics, stepId, "output", projected?.compiledNodeIndex ?? null);
@@ -178,6 +180,8 @@ export const SelectedCapabilityInspector = ({
key={`inputs:${stepId}:${controller.resetGeneration}`}
initialRows={inputRows}
inputSchema={capabilityDetail.inputSchema}
workflowInputSchema={isRecord(draft.draft) ? draft.draft.input_schema : undefined}
workflowStateSchema={isRecord(draft.draft) ? draft.draft.state_schema : undefined}
onDirtyChange={controller.markDirty}
onSubmit={controller.setStepInputs}
rowDiagnostics={inputDiagnostics.rowIssues}
@@ -42,11 +42,11 @@ describe("StepInputBindingsForm", () => {
);
expect(screen.getByRole("group", { name: "Input row 1" })).toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Target for row 1" })).toHaveValue("title");
expect(screen.getByRole("combobox", { name: "Target for row 1" })).toHaveValue("title");
expect(screen.getByRole("radio", { name: "Path for input row 1" })).toBeChecked();
expect(screen.getByRole("textbox", { name: "Source path for input row 1" })).toHaveValue("input.title");
expect(screen.getByRole("combobox", { name: "Source path for input row 1" })).toHaveValue("input.title");
expect(screen.getByRole("combobox", { name: "Nullable" })).toHaveValue("0:null");
expect(screen.getByRole("textbox", { name: "Target for row 3" })).toHaveValue("nested.name");
expect(screen.getByRole("combobox", { name: "Target for row 3" })).toHaveValue("nested.name");
expect(screen.getByText("Unsupported input binding.")).toBeInTheDocument();
expect(screen.getByRole("region", { name: "Raw unsupported input row 4" })).toHaveTextContent('"target"');
expect(screen.getByRole("button", { name: "Remove unsupported input row 4" })).toBeInTheDocument();
@@ -158,10 +158,10 @@ describe("StepInputBindingsForm", () => {
/>,
);
expect(screen.getByRole("textbox", { name: "Target for row 1" })).toHaveValue("payload.item");
expect(screen.getByRole("textbox", { name: "Target for row 2" })).toHaveValue(".");
expect(screen.getByRole("textbox", { name: "Source path for input row 1" })).toHaveValue("input.source");
expect(screen.getByRole("textbox", { name: "Source path for input row 2" })).toHaveValue("state.audit.latest");
expect(screen.getByRole("combobox", { name: "Target for row 1" })).toHaveValue("payload.item");
expect(screen.getByRole("combobox", { name: "Target for row 2" })).toHaveValue(".");
expect(screen.getByRole("combobox", { name: "Source path for input row 1" })).toHaveValue("input.source");
expect(screen.getByRole("combobox", { name: "Source path for input row 2" })).toHaveValue("state.audit.latest");
await user.click(screen.getByRole("button", { name: "Save inputs" }));
@@ -198,8 +198,8 @@ describe("StepInputBindingsForm", () => {
await user.clear(screen.getByRole("textbox", { name: "Name" }));
await user.type(screen.getByRole("textbox", { name: "Name" }), "after");
await user.click(screen.getByRole("radio", { name: "Path for input row 1" }));
await user.clear(screen.getByRole("textbox", { name: "Source path for input row 1" }));
await user.type(screen.getByRole("textbox", { name: "Source path for input row 1" }), "input.nested");
await user.clear(screen.getByRole("combobox", { name: "Source path for input row 1" }));
await user.type(screen.getByRole("combobox", { name: "Source path for input row 1" }), "input.nested");
await user.click(screen.getByRole("radio", { name: "Literal value for input row 1" }));
await user.click(screen.getByRole("button", { name: "Save inputs" }));
@@ -238,16 +238,85 @@ describe("StepInputBindingsForm", () => {
/>,
);
await user.clear(screen.getByRole("textbox", { name: "Target for row 1" }));
await user.clear(screen.getByRole("combobox", { name: "Target for row 1" }));
await user.click(screen.getByRole("button", { name: "Save inputs" }));
const target = screen.getByRole("textbox", { name: "Target for row 1" });
const target = screen.getByRole("combobox", { name: "Target for row 1" });
const describedBy = target.getAttribute("aria-describedby");
expect(target).toHaveAttribute("aria-invalid", "true");
expect(describedBy).toBeTruthy();
expect(document.getElementById(describedBy ?? "")).toHaveTextContent("Target is required.");
});
it("offers workflow source and nested capability target choices while retaining text entry", async () => {
const user = userEvent.setup();
const submissions: ReadonlyArray<InputBinding>[] = [];
render(
<StepInputBindingsForm
inputSchema={{
type: "object",
properties: { profile: { type: "object", properties: { name: { type: "string" } } } },
}}
workflowInputSchema={{
type: "object",
properties: { request: { type: "object", properties: { id: { type: "string" } } } },
}}
workflowStateSchema={{
type: "object",
properties: { session: { type: "object", properties: { token: { type: "string" } } } },
}}
initialBindings={[{ path: "input.request.id", target: "profile.name" }]}
onSubmit={(value) => { submissions.push(value); }}
/>,
);
const target = screen.getByRole("combobox", { name: "Target for row 1" });
const targetList = document.getElementById(target.getAttribute("list") ?? "");
expect(target.getAttribute("list")).toBeTruthy();
expect(targetList).not.toBeNull();
expect(targetList?.querySelector('option[value="profile.name"]')).not.toBeNull();
const source = screen.getByRole("combobox", { name: "Source path for input row 1" });
const sourceList = document.getElementById(source.getAttribute("list") ?? "");
expect(source.getAttribute("list")).toBeTruthy();
expect(sourceList).not.toBeNull();
expect(sourceList?.querySelector('option[value="input.request.id"]')).not.toBeNull();
expect(sourceList?.querySelector('option[value="state.session.token"]')).not.toBeNull();
await user.clear(target);
await user.type(target, "profile.custom");
await user.clear(source);
await user.type(source, "context.custom");
await user.click(screen.getByRole("button", { name: "Save inputs" }));
expect(submissions).toEqual([[{ path: "context.custom", target: "profile.custom" }]]);
});
it("rejects exact duplicate targets across path and literal rows with errors on both rows", async () => {
const user = userEvent.setup();
const submissions: ReadonlyArray<InputBinding>[] = [];
render(
<StepInputBindingsForm
inputSchema={schema}
initialRows={[
{ kind: "canonical", index: 0, value: { path: "input.title", target: "title" } },
{ kind: "canonical", index: 1, value: { target: "other", value: "literal" } },
]}
onSubmit={(value) => { submissions.push(value); }}
/>,
);
await user.clear(screen.getByRole("combobox", { name: "Target for row 2" }));
await user.type(screen.getByRole("combobox", { name: "Target for row 2" }), "title");
await user.click(screen.getByRole("button", { name: "Save inputs" }));
expect(submissions).toEqual([]);
expect(screen.getByRole("combobox", { name: "Target for row 1" })).toHaveAttribute("aria-invalid", "true");
expect(screen.getByRole("combobox", { name: "Target for row 2" })).toHaveAttribute("aria-invalid", "true");
expect(screen.getAllByRole("alert").filter((alert) =>
alert.textContent?.includes("Target is duplicated") ?? false,
)).toHaveLength(2);
});
it("shows row diagnostics at the row that owns them", () => {
render(
<StepInputBindingsForm
@@ -14,9 +14,11 @@ import { serializeSchemaValues, type FieldSources } from "../schema-form/schema-
import { formatBoundedJson } from "./format-bounded-json.js";
import { displayGraphInputPath, displayLocalInputPath } from "./input-binding-paths.js";
import {
capabilityLocalPathSuggestions,
inputBindingRows,
isJsonValue,
serializeInputBindingRow,
workflowSourceSuggestions,
type InputBindingRow,
} from "./selected-step-dataflow.js";
@@ -39,6 +41,8 @@ type FormRow = EditableRow | UnsupportedRow;
export type StepInputBindingsFormProps = {
readonly inputSchema: unknown;
readonly workflowInputSchema?: unknown;
readonly workflowStateSchema?: unknown;
readonly initialRows?: ReadonlyArray<InputBindingRow>;
readonly initialBindings?: ReadonlyArray<InputBinding>;
readonly rowDiagnostics?: Readonly<Record<number, ReadonlyArray<DraftDiagnostic>>>;
@@ -212,6 +216,8 @@ const bindingForRow = (
export const StepInputBindingsForm = ({
inputSchema,
workflowInputSchema,
workflowStateSchema,
initialRows,
initialBindings,
rowDiagnostics = EMPTY_DIAGNOSTICS,
@@ -221,6 +227,10 @@ export const StepInputBindingsForm = ({
}: StepInputBindingsFormProps) => {
const formId = useId();
const root = normalizeSchema(inputSchema);
const sourceSuggestions = workflowSourceSuggestions(workflowInputSchema ?? null, workflowStateSchema ?? null);
const targetSuggestions = capabilityLocalPathSuggestions(inputSchema);
const sourceListId = `${formId}-workflow-sources`;
const targetListId = `${formId}-capability-targets`;
const [rows, setRows] = useState<ReadonlyArray<FormRow>>(() =>
rowsFrom(inputRows(initialRows, initialBindings), formId),
);
@@ -285,7 +295,7 @@ export const StepInputBindingsForm = ({
const submit = (event: FormEvent<HTMLFormElement>): void => {
event.preventDefault();
const nextIssues: Record<string, ReadonlyArray<string>> = {};
const bindings: InputBinding[] = [];
const completed: Array<{ readonly id: string; readonly binding: InputBinding }> = [];
for (const row of rows) {
if (row.kind === "unsupported") {
nextIssues[row.id] = ["Remove or repair this unsupported input row before saving."];
@@ -293,14 +303,28 @@ export const StepInputBindingsForm = ({
}
const result = bindingForRow(root, row);
if (result.binding === null) nextIssues[row.id] = result.issues;
else bindings.push(result.binding);
else completed.push({ id: row.id, binding: result.binding });
}
const duplicateRows = new Map<string, string[]>();
for (const item of completed) {
const target = displayLocalInputPath(item.binding.target);
duplicateRows.set(target, [...(duplicateRows.get(target) ?? []), item.id]);
}
for (const ids of duplicateRows.values()) {
if (ids.length < 2) continue;
for (const id of ids) {
nextIssues[id] = [
...(nextIssues[id] ?? []),
"Target is duplicated in another input row.",
];
}
}
setLocalIssues(nextIssues);
setFormIssue(unsupportedRows.length > 0
? "Remove or repair every unsupported input row before saving."
: null);
if (Object.keys(nextIssues).length > 0) return;
void Promise.resolve(onSubmit(bindings)).catch(() => undefined);
void Promise.resolve(onSubmit(completed.map(({ binding }) => binding))).catch(() => undefined);
};
const clear = (): void => {
@@ -319,6 +343,12 @@ export const StepInputBindingsForm = ({
return (
<form className="schema-form authoring-form" noValidate onSubmit={submit}>
{formIssue !== null && <p id={formErrorId} role="alert">{formIssue}</p>}
<datalist id={sourceListId}>
{sourceSuggestions.map((suggestion) => <option key={suggestion} value={suggestion} />)}
</datalist>
<datalist id={targetListId}>
{targetSuggestions.map((suggestion) => <option key={suggestion} value={suggestion} />)}
</datalist>
<div className="schema-form__group">
{rows.length === 0 && <p>No input bindings configured.</p>}
{rows.map((row, index) => {
@@ -377,6 +407,7 @@ export const StepInputBindingsForm = ({
aria-invalid={hasIssues}
aria-label={`Target for row ${rowNumber}`}
id={targetId}
list={targetListId}
onChange={(event) => editRow(row.id, (current) => ({ ...current, target: event.target.value }))}
type="text"
value={row.target}
@@ -416,6 +447,7 @@ export const StepInputBindingsForm = ({
aria-invalid={hasIssues}
aria-label={`Source path for input row ${rowNumber}`}
id={pathId}
list={sourceListId}
onChange={(event) => editRow(row.id, (current) => ({ ...current, sourcePath: event.target.value }))}
type="text"
value={row.sourcePath}
@@ -295,6 +295,27 @@ describe("StepOutputBindingsForm", () => {
expect(screen.queryByRole("button", { name: "Confirm clear outputs" })).not.toBeInTheDocument();
});
it("requires fresh confirmation when saving after removing the last stored row", async () => {
const user = userEvent.setup();
const submissions: ReadonlyArray<OutputBinding>[] = [];
render(
<StepOutputBindingsForm
outputSchema={outputSchema}
stateSchema={stateSchema}
initialBindings={[{ source: "text", target: "state.existing" }]}
onSubmit={(value) => { submissions.push(value); }}
/>,
);
await user.click(screen.getByRole("button", { name: "Remove output row 1" }));
await user.click(screen.getByRole("button", { name: "Save outputs" }));
expect(submissions).toEqual([]);
expect(screen.getByRole("button", { name: "Confirm clear outputs" })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Confirm clear outputs" }));
expect(submissions).toEqual([[]]);
});
it.each(clearMutationCases)(
"cancels pending clear confirmation after $name and preserves the mutated rows",
async ({ mutate, expected }) => {
@@ -345,9 +345,11 @@ export const StepOutputBindingsForm = ({
const targetSuggestions = stateTargetSuggestions(stateSchema);
const targetListId = `${formId}-state-targets`;
const formErrorId = `${formId}-form-error`;
const initialRowValues = outputRows(initialRows, initialBindings);
const [rows, setRows] = useState<ReadonlyArray<FormRow>>(() =>
rowsFrom(outputRows(initialRows, initialBindings), formId, sourceSuggestions),
rowsFrom(initialRowValues, formId, sourceSuggestions),
);
const hadInitialRows = initialRowValues.length > 0;
const [localIssues, setLocalIssues] = useState<Readonly<Record<string, ReadonlyArray<string>>>>({});
const [formIssue, setFormIssue] = useState<string | null>(null);
const [clearConfirmation, setClearConfirmation] = useState(false);
@@ -432,6 +434,11 @@ export const StepOutputBindingsForm = ({
? "Remove or repair every unsupported output row before saving."
: null);
if (Object.keys(nextIssues).length > 0) return;
if (bindings.length === 0 && hadInitialRows) {
setFormIssue(CLEAR_COPY);
setClearConfirmation(true);
return;
}
void Promise.resolve(onSubmit(bindings)).catch(() => undefined);
};
@@ -161,6 +161,52 @@ describe("selected-step dataflow projection", () => {
});
});
it("preserves malformed binding containers and extra row keys for keyed and compiled steps", () => {
const extraInput = { path: "input.items", target: "items", extra: true };
const extraOutput = { source: "text", target: "state.report", extra: true };
expect(inputBindingRows(undefined)).toEqual([]);
expect(inputBindingRows(null)).toEqual([
expect.objectContaining({ kind: "unsupported", index: 0, raw: null }),
]);
expect(inputBindingRows({ path: "input.items", target: "items" })).toEqual([
expect.objectContaining({ kind: "unsupported", index: 0 }),
]);
expect(inputBindingRows([extraInput])).toEqual([
expect.objectContaining({ kind: "unsupported", index: 0, raw: extraInput }),
]);
expect(outputBindingRows(null)).toEqual([
expect.objectContaining({ kind: "unsupported", index: 0, raw: null }),
]);
expect(outputBindingRows({ source: "text", target: "state.report" })).toEqual([
expect.objectContaining({ kind: "unsupported", index: 0 }),
]);
expect(outputBindingRows([extraOutput])).toEqual([
expect.objectContaining({ kind: "unsupported", index: 0, raw: extraOutput }),
]);
const keyed = projectSelectedStepDataflow({
...keyedDraft,
draft: { steps: { render: { use: "wf.std.concat", input: null, output: extraOutput } } },
}, "render");
const compiled = projectSelectedStepDataflow({
...compiledDraft,
draft: { nodes: [{ id: "render", node: "wf.std.concat", input: extraInput, output: null }] },
}, "render");
expect(keyed?.inputs).toEqual([]);
expect(keyed?.outputs).toEqual([]);
expect(keyed?.unsupported.map(({ field, index, raw }) => ({ field, index, raw }))).toEqual([
{ field: "input", index: 0, raw: null },
{ field: "output", index: 0, raw: extraOutput },
]);
expect(compiled?.inputs).toEqual([]);
expect(compiled?.outputs).toEqual([]);
expect(compiled?.unsupported.map(({ field, index, raw }) => ({ field, index, raw }))).toEqual([
{ field: "input", index: 0, raw: extraInput },
{ field: "output", index: 0, raw: null },
]);
});
it("returns null for a missing step", () => {
expect(projectSelectedStepDataflow(keyedDraft, "missing")).toBeNull();
});
@@ -269,6 +315,17 @@ describe("selected-step schema helpers", () => {
expect(stateTargetSuggestions(schema)).toEqual(["state.items", "state.items.0", "state.account", "state.account.name"]);
});
it("combines workflow input and state source suggestions with nested local targets", () => {
expect(workflowSourceSuggestions(
{ type: "object", properties: { request: { type: "object", properties: { id: { type: "string" } } } } },
{ type: "object", properties: { session: { type: "object", properties: { token: { type: "string" } } } } },
)).toEqual(["input.request", "input.request.id", "state.session", "state.session.token"]);
expect(capabilityLocalPathSuggestions({
type: "object",
properties: { profile: { type: "object", properties: { name: { type: "string" } } } },
})).toEqual([".", "profile", "profile.name"]);
});
it("previews only the selected output source schema", () => {
const outputSchema = {
type: "object",
@@ -26,6 +26,11 @@ const isRecord = (value: unknown): value is JsonRecord =>
const hasOwn = (value: JsonRecord, key: string): boolean =>
Object.prototype.hasOwnProperty.call(value, key);
const hasExactKeys = (value: JsonRecord, keys: ReadonlyArray<string>): boolean => {
const actual = Reflect.ownKeys(value);
return actual.length === keys.length && keys.every((key) => actual.includes(key));
};
/** Guard the recursive JSON subset used by literal input bindings. */
export const isJsonValue = (value: unknown): value is JsonValue => {
if (value === null || typeof value === "boolean" || typeof value === "string") return true;
@@ -65,6 +70,7 @@ const localPathParts = (value: unknown): string[] | null => {
return parts === null ? null : [...parts];
}
if (!isRecord(value) || value.root !== "local") return null;
if (!hasExactKeys(value, ["root", "parts"])) return null;
const parts = stringParts(value.parts);
return parts !== null && validPathParts(parts) ? parts : null;
};
@@ -80,6 +86,7 @@ const inputPathParts = (value: unknown): string[] | null => {
}
if (!isRecord(value)) return null;
if (value.root !== "input" && value.root !== "state" && value.root !== "context") return null;
if (!hasExactKeys(value, ["root", "parts"])) return null;
const parts = stringParts(value.parts);
return parts !== null && validPathParts(parts) ? [value.root, ...parts] : null;
};
@@ -90,6 +97,7 @@ const statePathParts = (value: unknown): string[] | null => {
return parts !== null && parts.length > 1 && parts[0] === "state" ? [...parts] : null;
}
if (!isRecord(value) || value.root !== "state") return null;
if (!hasExactKeys(value, ["root", "parts"])) return null;
const parts = stringParts(value.parts);
return parts !== null && parts.length > 0 && validPathParts(parts) ? ["state", ...parts] : null;
};
@@ -141,14 +149,17 @@ const parsedInputBinding = (value: unknown): InputBinding | null => {
const hasValue = hasOwn(value, "value");
if (hasPath === hasValue) return null;
if (hasPath) {
if (!hasExactKeys(value, ["path", "target"])) return null;
const path = inputPath(value.path);
return path === null ? null : { path, target };
}
if (!hasExactKeys(value, ["target", "value"])) return null;
return !isJsonValue(value.value) ? null : { target, value: value.value };
};
const parsedOutputBinding = (value: unknown): OutputBinding | null => {
if (!isRecord(value)) return null;
if (!hasExactKeys(value, ["source", "target"])) return null;
const source = localPath(value.source);
const target = statePath(value.target);
return source === null || target === null ? null : { source, target };
@@ -169,7 +180,13 @@ const parseRows = <T>(
): ParsedRows<T> => {
const values: T[] = [];
const unsupported: UnsupportedBindingRow[] = [];
const rows = Array.isArray(raw) ? raw : raw === undefined || raw === null ? [] : [raw];
if (raw !== undefined && !Array.isArray(raw)) {
return {
values,
unsupported: [{ field, index: 0, raw, reason: unsupportedReason(field, 0) }],
};
}
const rows = raw === undefined ? [] : raw;
rows.forEach((value, index) => {
const parsed = parse(value);
if (parsed === null) {
@@ -268,7 +285,10 @@ const rowsFor = <T>(
field: "input" | "output",
): ReadonlyArray<BindingRow<T>> => {
const rows: Array<BindingRow<T>> = [];
const values = Array.isArray(raw) ? raw : raw === undefined || raw === null ? [] : [raw];
if (raw !== undefined && !Array.isArray(raw)) {
return [{ kind: "unsupported", field, index: 0, raw, reason: unsupportedReason(field, 0) }];
}
const values = raw === undefined ? [] : raw;
values.forEach((value, index) => {
const parsed = parse(value);
if (parsed === null) {
@@ -291,10 +311,12 @@ export const serializeInputBindingRow = (value: unknown): InputBinding | null =>
const target = canonicalLocalPath(value.target);
if (target === null) return null;
if (hasOwn(value, "path") && !hasOwn(value, "value")) {
if (!hasExactKeys(value, ["path", "target"])) return null;
const path = canonicalInputPath(value.path);
return path === null ? null : { path, target };
}
if (!hasOwn(value, "path") && hasOwn(value, "value") && isJsonValue(value.value)) {
if (!hasExactKeys(value, ["target", "value"])) return null;
return { target, value: value.value };
}
return null;
@@ -315,6 +337,7 @@ export const serializeInputBindingRows = (
export const serializeOutputBindingRow = (value: unknown): OutputBinding | null => {
if (!isRecord(value)) return null;
if (!hasExactKeys(value, ["source", "target"])) return null;
const source = canonicalLocalPath(value.source);
const target = canonicalStatePath(value.target);
return source === null || target === null ? null : { source, target };