fix: close Task 5 contract graph review findings
This commit is contained in:
@@ -41,6 +41,10 @@ const options: ReadonlyArray<AuthoringPathOption> = [
|
||||
availability: "conditional",
|
||||
reason: "Available when the selected step runs in a viewer frame.",
|
||||
}),
|
||||
option("context.viewer_email", "Viewer email", "runtime_context", ["workflow_output"], {
|
||||
availability: "conditional",
|
||||
reason: "Available only in viewer frames.",
|
||||
}),
|
||||
option("output.report", "Final report", "workflow_output", ["workflow_output"]),
|
||||
option("input.customer.name", "Customer name", "workflow_input", ["step_input"]),
|
||||
];
|
||||
@@ -133,6 +137,55 @@ describe("AuthoringPathPicker", () => {
|
||||
expect(onChange).toHaveBeenCalledWith("input.title");
|
||||
});
|
||||
|
||||
it("gives a conditional incompatible option one composed description node", () => {
|
||||
render(
|
||||
<AuthoringPathPicker
|
||||
label="Source path"
|
||||
onChange={vi.fn()}
|
||||
options={options}
|
||||
uses="step_input"
|
||||
value=""
|
||||
/>,
|
||||
);
|
||||
|
||||
const optionButton = screen.getByRole("button", { name: /Viewer email/ });
|
||||
const describedBy = optionButton.getAttribute("aria-describedby");
|
||||
|
||||
expect(optionButton).toBeDisabled();
|
||||
expect(describedBy).not.toBeNull();
|
||||
expect(describedBy?.trim().split(/\s+/)).toHaveLength(1);
|
||||
expect(document.querySelectorAll(`#${describedBy}`).length).toBe(1);
|
||||
expect(document.getElementById(describedBy ?? "")?.textContent).toContain(
|
||||
"Available only in viewer frames.",
|
||||
);
|
||||
expect(document.getElementById(describedBy ?? "")?.textContent).toContain(
|
||||
"Not available for this field.",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders a supplied non-conditional reason before describing the option", () => {
|
||||
render(
|
||||
<AuthoringPathPicker
|
||||
label="Source path"
|
||||
onChange={vi.fn()}
|
||||
options={[
|
||||
option("input.reasoned", "Reasoned", "workflow_input", ["step_input"], {
|
||||
reason: "Retained for repair context.",
|
||||
}),
|
||||
]}
|
||||
uses="step_input"
|
||||
value=""
|
||||
/>,
|
||||
);
|
||||
|
||||
const optionButton = screen.getByRole("button", { name: /Reasoned/ });
|
||||
const describedBy = optionButton.getAttribute("aria-describedby");
|
||||
expect(describedBy).not.toBeNull();
|
||||
expect(document.getElementById(describedBy ?? "")?.textContent).toBe(
|
||||
"Retained for repair context.",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps nested choices keyboard reachable and preserves normal choices in Advanced mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
|
||||
@@ -80,14 +80,19 @@ export const AuthoringPathPicker = ({
|
||||
<fieldset className="authoring-path-picker__group" key={group.origin}>
|
||||
<legend>{group.label}</legend>
|
||||
<div className="authoring-path-picker__list">
|
||||
{groupOptions.map((option) => {
|
||||
{groupOptions.map((option, optionIndex) => {
|
||||
const compatible = option.uses.some((use) => requestedUses.has(use));
|
||||
const reasonId = `${id}-${safeId(option.path)}-reason`;
|
||||
const reasonId = `${id}-${safeId(group.origin)}-${optionIndex}-description`;
|
||||
const availabilityReason = option.availability === "conditional"
|
||||
? option.reason ?? "Conditionally available; verify this path at runtime."
|
||||
: option.reason;
|
||||
const description = [
|
||||
availabilityReason,
|
||||
...(compatible ? [] : ["Not available for this field."]),
|
||||
].filter((part): part is string => part !== undefined).join(" ");
|
||||
return (
|
||||
<button
|
||||
aria-describedby={
|
||||
option.reason !== undefined || !compatible ? reasonId : undefined
|
||||
}
|
||||
aria-describedby={description === "" ? undefined : reasonId}
|
||||
aria-pressed={option.path === value}
|
||||
className="authoring-path-picker__option"
|
||||
disabled={!compatible}
|
||||
@@ -102,10 +107,7 @@ export const AuthoringPathPicker = ({
|
||||
<code>{option.path}</code>
|
||||
{option.description !== undefined && <span>{option.description}</span>}
|
||||
{option.required && <small>Required</small>}
|
||||
{option.availability === "conditional" && option.reason !== undefined && (
|
||||
<small id={reasonId}>{option.reason}</small>
|
||||
)}
|
||||
{!compatible && <small id={reasonId}>Not available for this field.</small>}
|
||||
{description !== "" && <small id={reasonId}>{description}</small>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -125,6 +125,19 @@ describe("useAuthoringContract", () => {
|
||||
expect(result.current.inventory?.selectedStepId).toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces an initial inspection failure instead of staying loading", async () => {
|
||||
client.inspect.mockRejectedValue(new Error("initial inspection failed"));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAuthoringContract({ workspaceId: "draft-report", revision: 7, selectedStepId: null }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.phase).toBe("error"));
|
||||
|
||||
expect(result.current.inventory).toBeNull();
|
||||
expect(result.current.message).toBe("initial inspection failed");
|
||||
});
|
||||
|
||||
it("passes the executable step id and ignores a stale selection response", async () => {
|
||||
const first = deferred<AuthoringContractInventory>();
|
||||
const second = deferred<AuthoringContractInventory>();
|
||||
|
||||
@@ -42,12 +42,14 @@ type StoredInventory = {
|
||||
|
||||
type AuthoringContractState = {
|
||||
readonly phase: AuthoringContractPhase;
|
||||
readonly attempted: RequestIdentity | null;
|
||||
readonly stored: StoredInventory | null;
|
||||
readonly message: string | null;
|
||||
};
|
||||
|
||||
const initialState: AuthoringContractState = {
|
||||
phase: "disconnected",
|
||||
attempted: null,
|
||||
stored: null,
|
||||
message: null,
|
||||
};
|
||||
@@ -98,6 +100,7 @@ export const useAuthoringContract = ({
|
||||
const requested = request;
|
||||
setState((current) => ({
|
||||
phase: "loading",
|
||||
attempted: requested,
|
||||
stored:
|
||||
current.stored !== null && sameRequest(current.stored.request, requested)
|
||||
? current.stored
|
||||
@@ -115,6 +118,7 @@ export const useAuthoringContract = ({
|
||||
if (generation !== generationRef.current) return;
|
||||
setState({
|
||||
phase: "ready",
|
||||
attempted: requested,
|
||||
stored: { request: requested, inventory },
|
||||
message: null,
|
||||
});
|
||||
@@ -134,6 +138,7 @@ export const useAuthoringContract = ({
|
||||
generationRef.current++;
|
||||
setState({
|
||||
phase: client === null || connectedTarget === null ? "disconnected" : "idle",
|
||||
attempted: null,
|
||||
stored: null,
|
||||
message: null,
|
||||
});
|
||||
@@ -150,20 +155,28 @@ export const useAuthoringContract = ({
|
||||
request !== null && state.stored !== null && sameRequest(state.stored.request, request)
|
||||
? state.stored.inventory
|
||||
: null;
|
||||
const phase =
|
||||
const hasCurrentAttempt =
|
||||
request !== null &&
|
||||
(state.stored === null || !sameRequest(state.stored.request, request))
|
||||
? "loading"
|
||||
: request === null
|
||||
? client === null || connectedTarget === null
|
||||
? "disconnected"
|
||||
: "idle"
|
||||
: state.phase;
|
||||
state.attempted !== null &&
|
||||
sameRequest(state.attempted, request);
|
||||
const phase = request === null
|
||||
? client === null || connectedTarget === null
|
||||
? "disconnected"
|
||||
: "idle"
|
||||
: hasCurrentAttempt && state.phase === "error"
|
||||
? "error"
|
||||
: state.stored !== null && sameRequest(state.stored.request, request)
|
||||
? state.phase
|
||||
: "loading";
|
||||
|
||||
return {
|
||||
phase,
|
||||
inventory: currentInventory,
|
||||
message: currentInventory === null && phase === "loading" ? null : state.message,
|
||||
message: hasCurrentAttempt && state.phase === "error"
|
||||
? state.message
|
||||
: currentInventory === null && phase === "loading"
|
||||
? null
|
||||
: state.message,
|
||||
refresh,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -43,7 +43,7 @@ const runWith = (
|
||||
|
||||
describe("AuthoringContractClient", () => {
|
||||
it("sends the exact inspection params and decodes the response", async () => {
|
||||
const { executor, calls } = runWith(wireInventory);
|
||||
const { executor, calls } = runWith({ ...wireInventory, selected_step_id: "render" });
|
||||
const client = createAuthoringContractClient(executor);
|
||||
|
||||
const result = await client.inspect({
|
||||
@@ -80,6 +80,7 @@ describe("AuthoringContractClient", () => {
|
||||
it.each([
|
||||
["workspaceId", { workspace_id: "other" }],
|
||||
["revision", { revision: 8 }],
|
||||
["selectedStepId", { selected_step_id: "render" }],
|
||||
])("rejects a response with a mismatched %s", async (_field, replacement) => {
|
||||
const { executor } = runWith({ ...wireInventory, ...replacement });
|
||||
const client = createAuthoringContractClient(executor);
|
||||
@@ -88,4 +89,13 @@ describe("AuthoringContractClient", () => {
|
||||
client.inspect({ workspaceId: "draft-report", revision: 7, selectedStepId: null }),
|
||||
).rejects.toThrow("does not match inspection request");
|
||||
});
|
||||
|
||||
it("rejects a response for a different executable step", async () => {
|
||||
const { executor } = runWith({ ...wireInventory, selected_step_id: "other" });
|
||||
const client = createAuthoringContractClient(executor);
|
||||
|
||||
await expect(
|
||||
client.inspect({ workspaceId: "draft-report", revision: 7, selectedStepId: "render" }),
|
||||
).rejects.toThrow("does not match inspection request");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,7 +42,8 @@ export const createAuthoringContractClient = (
|
||||
);
|
||||
if (
|
||||
inventory.workspaceId !== workspaceId ||
|
||||
inventory.revision !== input.revision
|
||||
inventory.revision !== input.revision ||
|
||||
(inventory.selectedStepId?.trim() || null) !== selectedStepId
|
||||
) {
|
||||
throw new Error("authoring contract response does not match inspection request");
|
||||
}
|
||||
|
||||
@@ -96,11 +96,23 @@ describe("authoring contract models", () => {
|
||||
});
|
||||
|
||||
it("rejects a malformed inventory envelope", () => {
|
||||
expect(() => decodeAuthoringContractInventory({ ...inventory, revision: "7" })).toThrow(
|
||||
for (const revision of [0, -1, 1.5, "7"]) {
|
||||
expect(() => decodeAuthoringContractInventory({ ...inventory, revision })).toThrow(
|
||||
"AuthoringContractInventory is malformed",
|
||||
);
|
||||
}
|
||||
expect(() => decodeAuthoringContractInventory({ ...inventory, selected_step_id: "" })).toThrow(
|
||||
"AuthoringContractInventory is malformed",
|
||||
);
|
||||
});
|
||||
|
||||
it("requires a reason for conditional options", () => {
|
||||
expect(() => decodeAuthoringContractInventory({
|
||||
...inventory,
|
||||
readable_sources: [{ ...pathOption, availability: "conditional", reason: undefined }],
|
||||
})).toThrow("AuthoringContractInventory is malformed");
|
||||
});
|
||||
|
||||
const _typeCheck: AuthoringContractInventory | null = null;
|
||||
void _typeCheck;
|
||||
});
|
||||
|
||||
@@ -52,6 +52,8 @@ export type AuthoringContractInventory = {
|
||||
};
|
||||
|
||||
const JsonObjectSchema = v.record(v.string(), v.unknown());
|
||||
const PositiveRevisionSchema = v.pipe(v.number(), v.integer(), v.minValue(1));
|
||||
const SelectedStepIdSchema = v.nullable(v.pipe(v.string(), v.minLength(1)));
|
||||
|
||||
const AuthoringPathOriginSchema = v.union([
|
||||
v.literal("workflow_input"),
|
||||
@@ -74,77 +76,73 @@ const AuthoringPathUseSchema = v.union([
|
||||
v.literal("workflow_output"),
|
||||
]);
|
||||
|
||||
const AuthoringPathOptionWireSchema = v.object({
|
||||
path: v.string(),
|
||||
label: v.string(),
|
||||
origin: AuthoringPathOriginSchema,
|
||||
schema: JsonObjectSchema,
|
||||
required: v.boolean(),
|
||||
availability: AuthoringPathAvailabilitySchema,
|
||||
uses: v.array(AuthoringPathUseSchema),
|
||||
description: v.optional(v.string()),
|
||||
reason: v.optional(v.string()),
|
||||
});
|
||||
const AuthoringPathOptionSchema = v.pipe(
|
||||
v.object({
|
||||
path: v.string(),
|
||||
label: v.string(),
|
||||
origin: AuthoringPathOriginSchema,
|
||||
schema: JsonObjectSchema,
|
||||
required: v.boolean(),
|
||||
availability: AuthoringPathAvailabilitySchema,
|
||||
uses: v.array(AuthoringPathUseSchema),
|
||||
description: v.optional(v.string()),
|
||||
reason: v.optional(v.string()),
|
||||
}),
|
||||
v.check(
|
||||
(option) =>
|
||||
option.availability !== "conditional" ||
|
||||
(option.reason !== undefined && option.reason.trim().length > 0),
|
||||
"conditional authoring options require a non-empty reason",
|
||||
),
|
||||
);
|
||||
|
||||
const AuthoringStepContractWireSchema = v.object({
|
||||
step_id: v.string(),
|
||||
label: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
input_targets: v.optional(v.array(AuthoringPathOptionWireSchema)),
|
||||
output_sources: v.optional(v.array(AuthoringPathOptionWireSchema)),
|
||||
input_targets: v.optional(v.array(AuthoringPathOptionSchema)),
|
||||
output_sources: v.optional(v.array(AuthoringPathOptionSchema)),
|
||||
outcomes: v.optional(v.array(v.string())),
|
||||
});
|
||||
|
||||
const AuthoringContractInventoryWireSchema = v.object({
|
||||
workspace_id: v.string(),
|
||||
revision: v.number(),
|
||||
selected_step_id: v.nullable(v.string()),
|
||||
readable_sources: v.array(AuthoringPathOptionWireSchema),
|
||||
step_input_targets: v.array(AuthoringPathOptionWireSchema),
|
||||
step_output_sources: v.array(AuthoringPathOptionWireSchema),
|
||||
state_targets: v.array(AuthoringPathOptionWireSchema),
|
||||
workflow_output_targets: v.array(AuthoringPathOptionWireSchema),
|
||||
revision: PositiveRevisionSchema,
|
||||
selected_step_id: SelectedStepIdSchema,
|
||||
readable_sources: v.array(AuthoringPathOptionSchema),
|
||||
step_input_targets: v.array(AuthoringPathOptionSchema),
|
||||
step_output_sources: v.array(AuthoringPathOptionSchema),
|
||||
state_targets: v.array(AuthoringPathOptionSchema),
|
||||
workflow_output_targets: v.array(AuthoringPathOptionSchema),
|
||||
entry_steps: v.array(AuthoringStepContractWireSchema),
|
||||
workflow_outcomes: v.array(v.string()),
|
||||
warnings: v.array(v.string()),
|
||||
});
|
||||
|
||||
const AuthoringPathOptionBrowserSchema = v.object({
|
||||
path: v.string(),
|
||||
label: v.string(),
|
||||
origin: AuthoringPathOriginSchema,
|
||||
schema: JsonObjectSchema,
|
||||
required: v.boolean(),
|
||||
availability: AuthoringPathAvailabilitySchema,
|
||||
uses: v.array(AuthoringPathUseSchema),
|
||||
description: v.optional(v.string()),
|
||||
reason: v.optional(v.string()),
|
||||
});
|
||||
|
||||
const AuthoringStepContractBrowserSchema = v.object({
|
||||
stepId: v.string(),
|
||||
label: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
inputTargets: v.optional(v.array(AuthoringPathOptionBrowserSchema)),
|
||||
outputSources: v.optional(v.array(AuthoringPathOptionBrowserSchema)),
|
||||
inputTargets: v.optional(v.array(AuthoringPathOptionSchema)),
|
||||
outputSources: v.optional(v.array(AuthoringPathOptionSchema)),
|
||||
outcomes: v.optional(v.array(v.string())),
|
||||
});
|
||||
|
||||
const AuthoringContractInventoryBrowserSchema = v.object({
|
||||
workspaceId: v.string(),
|
||||
revision: v.number(),
|
||||
selectedStepId: v.nullable(v.string()),
|
||||
readableSources: v.array(AuthoringPathOptionBrowserSchema),
|
||||
stepInputTargets: v.array(AuthoringPathOptionBrowserSchema),
|
||||
stepOutputSources: v.array(AuthoringPathOptionBrowserSchema),
|
||||
stateTargets: v.array(AuthoringPathOptionBrowserSchema),
|
||||
workflowOutputTargets: v.array(AuthoringPathOptionBrowserSchema),
|
||||
revision: PositiveRevisionSchema,
|
||||
selectedStepId: SelectedStepIdSchema,
|
||||
readableSources: v.array(AuthoringPathOptionSchema),
|
||||
stepInputTargets: v.array(AuthoringPathOptionSchema),
|
||||
stepOutputSources: v.array(AuthoringPathOptionSchema),
|
||||
stateTargets: v.array(AuthoringPathOptionSchema),
|
||||
workflowOutputTargets: v.array(AuthoringPathOptionSchema),
|
||||
entrySteps: v.array(AuthoringStepContractBrowserSchema),
|
||||
workflowOutcomes: v.array(v.string()),
|
||||
warnings: v.array(v.string()),
|
||||
});
|
||||
|
||||
type AuthoringPathOptionWire = v.InferOutput<typeof AuthoringPathOptionWireSchema>;
|
||||
type AuthoringPathOptionWire = v.InferOutput<typeof AuthoringPathOptionSchema>;
|
||||
type AuthoringStepContractWire = v.InferOutput<typeof AuthoringStepContractWireSchema>;
|
||||
type AuthoringContractInventoryWire = v.InferOutput<
|
||||
typeof AuthoringContractInventoryWireSchema
|
||||
|
||||
Reference in New Issue
Block a user