fix: harden authoring schema and contract projections

This commit is contained in:
lda
2026-08-29 21:08:05 +07:00 Verified
parent 8b16c6403d
commit 0570ef8a78
14 changed files with 243 additions and 359 deletions
@@ -95,4 +95,32 @@ describe("WorkflowContractInspector", () => {
await user.click(screen.getByRole("button", { name: "Save outcomes" }));
expect(controller.setContract).toHaveBeenCalledWith({ outcomes: ["ok"] });
});
it("marks output reordering as dirty", async () => {
const user = userEvent.setup();
controller.markDirty.mockClear();
const outputDraft = {
...draft,
draft: {
...draft.draft,
output: [
{ path: "state.report", target: "report" },
{ path: "state.report", target: "report_copy" },
],
},
} satisfies DraftWorkspace;
render(<WorkflowContractInspector contract="output" controller={controller} draft={outputDraft} inventory={inventory} />);
await user.click(screen.getAllByRole("button", { name: "Move up" })[1]!);
expect(controller.markDirty).toHaveBeenCalled();
});
it("marks outcome removal as dirty", async () => {
const user = userEvent.setup();
controller.markDirty.mockClear();
render(<WorkflowContractInspector contract="outcomes" controller={controller} draft={draft} inventory={inventory} />);
await user.click(screen.getAllByRole("button", { name: "Remove" })[0]!);
expect(controller.markDirty).toHaveBeenCalled();
});
});
@@ -127,7 +127,20 @@ const WorkflowOutputBindingsForm = ({ controller, draft, inventory }: WorkflowCo
) : <label>Literal JSON<textarea value={row.value} onChange={(event) => update(row.id, { value: event.target.value })} /></label>}
<label>Output target<select value={row.target} onChange={(event) => update(row.id, { target: event.target.value })}><option value="">Choose output field</option>{targets.map((target) => <option key={target.path} value={target.path.replace(/^output\./, "")}>{target.label}</option>)}</select></label>
<div className="workflow-output-bindings__actions">
<button disabled={index === 0} onClick={() => setRows((current) => { const copy = [...current]; [copy[index - 1], copy[index]] = [copy[index]!, copy[index - 1]!]; return copy; })} type="button">Move up</button>
<button
disabled={index === 0}
onClick={() => {
setRows((current) => {
const copy = [...current];
[copy[index - 1], copy[index]] = [copy[index]!, copy[index - 1]!];
return copy;
});
controller.markDirty();
}}
type="button"
>
Move up
</button>
<button onClick={() => { setRows((current) => current.filter((item) => item.id !== row.id)); controller.markDirty(); }} type="button">Remove binding</button>
</div>
</fieldset>
@@ -143,7 +156,7 @@ const OutcomesForm = ({ controller, draft }: WorkflowContractInspectorProps) =>
const [rows, setRows] = useState<ReadonlyArray<string>>(() => Array.isArray(raw) ? raw.map(String) : []);
return (
<form className="workflow-outcomes-form" onSubmit={(event) => { event.preventDefault(); void controller.setContract({ outcomes: normalizeOutcomes(rows) }); }}>
{rows.map((outcome, index) => <div key={index}><label>Outcome {index + 1}<input value={outcome} onChange={(event) => { setRows((current) => current.map((item, itemIndex) => itemIndex === index ? event.target.value : item)); controller.markDirty(); }} /></label><button onClick={() => setRows((current) => current.filter((_, itemIndex) => itemIndex !== index))} type="button">Remove</button></div>)}
{rows.map((outcome, index) => <div key={index}><label>Outcome {index + 1}<input value={outcome} onChange={(event) => { setRows((current) => current.map((item, itemIndex) => itemIndex === index ? event.target.value : item)); controller.markDirty(); }} /></label><button onClick={() => { setRows((current) => current.filter((_, itemIndex) => itemIndex !== index)); controller.markDirty(); }} type="button">Remove</button></div>)}
<button onClick={() => { setRows((current) => [...current, ""]); controller.markDirty(); }} type="button">Add outcome</button>
<button type="submit">Save outcomes</button>
</form>
@@ -1,4 +1,4 @@
import { cleanup, render, screen } from "@testing-library/react";
import { cleanup, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { WorkflowSchemaFieldsForm } from "./WorkflowSchemaFieldsForm.js";
@@ -79,4 +79,68 @@ describe("WorkflowSchemaFieldsForm", () => {
await user.click(screen.getByRole("button", { name: "Save output schema" }));
expect(onSubmit).toHaveBeenLastCalledWith(expect.objectContaining({ properties: {} }));
});
it("updates dotted property names independently from nested properties", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(
<WorkflowSchemaFieldsForm
contract="input"
onSubmit={onSubmit}
schema={{
type: "object",
properties: {
"a.b": { type: "string", description: "Flat property" },
a: {
type: "object",
properties: { b: { type: "string", description: "Nested property" } },
},
},
}}
/>,
);
const nested = screen.getByRole("group", { name: "Schema field b" });
const description = within(nested).getByRole("textbox", { name: "Description" });
await user.clear(description);
await user.type(description, "Updated nested property");
await user.click(screen.getByRole("button", { name: "Save input schema" }));
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
properties: expect.objectContaining({
"a.b": expect.objectContaining({ description: "Flat property" }),
a: expect.objectContaining({
properties: { b: expect.objectContaining({ description: "Updated nested property" }) },
}),
}),
}),
);
});
it("rejects blank and duplicate field names before serializing", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(
<WorkflowSchemaFieldsForm
contract="output"
onSubmit={onSubmit}
schema={{
type: "object",
properties: { first: { type: "string" }, second: { type: "string" } },
}}
/>,
);
const names = screen.getAllByRole("textbox", { name: "Field name" });
await user.clear(names[1]!);
await user.click(screen.getByRole("button", { name: "Save output schema" }));
expect(onSubmit).not.toHaveBeenCalled();
expect(screen.getByRole("alert")).toHaveTextContent("must not be blank");
await user.type(names[1]!, "first");
await user.click(screen.getByRole("button", { name: "Save output schema" }));
expect(onSubmit).not.toHaveBeenCalled();
expect(screen.getByRole("alert")).toHaveTextContent("must be unique");
});
});
@@ -4,6 +4,7 @@ import type { JsonObject } from "../domain/draft-workspace-models.js";
import {
projectWorkflowSchema,
serializeWorkflowSchema,
validateWorkflowSchemaRows,
type WorkflowContractKind,
type WorkflowSchemaFieldRow,
type WorkflowSchemaFieldType,
@@ -210,8 +211,12 @@ export const WorkflowSchemaFieldsForm = ({
}: WorkflowSchemaFieldsFormProps) => {
const [projection] = useState(() => projectWorkflowSchema(schema));
const [rows, setRows] = useState(projection.rows);
const [validationError, setValidationError] = useState<string | null>(null);
const nextId = useRef(0);
const markDirty = (): void => onDirtyChange?.(true);
const markDirty = (): void => {
setValidationError(null);
onDirtyChange?.(true);
};
const createId = (): string => `new-field-${nextId.current++}`;
const update = (
id: string,
@@ -226,6 +231,11 @@ export const WorkflowSchemaFieldsForm = ({
};
const submit = (event: FormEvent<HTMLFormElement>): void => {
event.preventDefault();
const issues = validateWorkflowSchemaRows(rows);
if (issues.length > 0) {
setValidationError(issues.join(" "));
return;
}
void Promise.resolve(
onSubmit(serializeWorkflowSchema(projection, rows, { state: contract === "state" })),
).catch(() => undefined);
@@ -233,6 +243,7 @@ export const WorkflowSchemaFieldsForm = ({
return (
<form className="workflow-schema-fields-form" noValidate onSubmit={submit}>
{validationError !== null && <p role="alert">{validationError}</p>}
{projection.rootUnsupportedReason !== null ? (
<section aria-label="Unsupported root schema" className="workflow-schema-field--unsupported">
<p>{projection.rootUnsupportedReason}</p>
@@ -45,12 +45,21 @@ export type WorkflowContractPatch = {
readonly outcomes?: ReadonlyArray<string>;
};
type WorkflowSchemaPathSegment =
| { readonly kind: "property"; readonly name: string }
| { readonly kind: "array-item" };
const MAX_DEPTH = 64;
const COMPOSITION_KEYS = ["oneOf", "anyOf", "allOf", "not", "if", "then", "else"] as const;
const isRecord = (value: unknown): value is JsonObject =>
typeof value === "object" && value !== null && !Array.isArray(value);
const schemaFieldId = (path: ReadonlyArray<WorkflowSchemaPathSegment>): string =>
// JSON-encoded tagged segments keep a property named "a.b" distinct from
// a nested property path ["a", "b"].
JSON.stringify(path);
export const copyJson = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(copyJson);
if (!isRecord(value)) return value;
@@ -97,7 +106,7 @@ const projectField = (
name: string,
schema: unknown,
required: boolean,
path: string,
path: ReadonlyArray<WorkflowSchemaPathSegment>,
depth: number,
): WorkflowSchemaFieldRow => {
const raw = isRecord(schema) ? copyObject(schema) : {};
@@ -117,16 +126,16 @@ const projectField = (
childName,
childSchema,
requiredNames.has(childName),
`${path}.${childName}`,
[...path, { kind: "property", name: childName }],
depth + 1,
),
)
: [];
const item = reason === null && type === "array" && raw.items !== undefined
? projectField("item", raw.items, true, `${path}.items`, depth + 1)
? projectField("item", raw.items, true, [...path, { kind: "array-item" }], depth + 1)
: null;
return {
id: path,
id: schemaFieldId(path),
name,
type,
required,
@@ -154,13 +163,37 @@ export const projectWorkflowSchema = (schema: unknown): WorkflowSchemaProjection
schema: root,
rows: rootReason === null
? Object.entries(properties).map(([name, field]) =>
projectField(name, field, requiredNames.has(name), name, 1),
projectField(
name,
field,
requiredNames.has(name),
[{ kind: "property", name }],
1,
),
)
: [],
rootUnsupportedReason: rootReason,
};
};
export const validateWorkflowSchemaRows = (
rows: ReadonlyArray<WorkflowSchemaFieldRow>,
): ReadonlyArray<string> => {
const issues = new Set<string>();
const visit = (scopeRows: ReadonlyArray<WorkflowSchemaFieldRow>): void => {
const names = new Set<string>();
for (const row of scopeRows) {
if (row.name.trim() === "") issues.add("Field names must not be blank.");
else if (names.has(row.name)) issues.add("Field names must be unique within each object.");
names.add(row.name);
visit(row.children);
if (row.item !== null) visit([row.item]);
}
};
visit(rows);
return [...issues];
};
const serializeField = (row: WorkflowSchemaFieldRow, state: boolean): JsonObject => {
if (row.unsupportedReason !== null) return copyObject(row.raw);
const next = copyObject(row.raw);
@@ -200,6 +233,8 @@ export const serializeWorkflowSchema = (
options: { readonly state?: boolean } = {},
): JsonObject => {
if (projection.rootUnsupportedReason !== null) return copyObject(projection.schema);
const issues = validateWorkflowSchemaRows(rows);
if (issues.length > 0) throw new Error(issues.join(" "));
const next = copyObject(projection.schema);
next.type = "object";
next.properties = Object.fromEntries(