feat: browse authoring contract paths

This commit is contained in:
lda
2026-08-14 18:19:29 +07:00 Verified
parent 804946531a
commit 50a462f927
9 changed files with 1268 additions and 0 deletions
@@ -0,0 +1,163 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import type {
AuthoringPathOption,
AuthoringPathUse,
} from "../domain/authoring-contract-models.js";
import { AuthoringPathPicker } from "./AuthoringPathPicker.js";
afterEach(() => {
cleanup();
});
const option = (
path: string,
label: string,
origin: AuthoringPathOption["origin"],
uses: ReadonlyArray<AuthoringPathUse>,
extras: Partial<AuthoringPathOption> = {},
): AuthoringPathOption => ({
path,
label,
origin,
schema: { type: "string" },
required: false,
availability: "available",
uses,
...extras,
});
const options: ReadonlyArray<AuthoringPathOption> = [
option("input.title", "Title", "workflow_input", ["step_input"], {
description: "The report title.",
required: true,
}),
option("state.report", "Report state", "workflow_state", ["step_input"]),
option("step_output.markdown", "Markdown", "step_output", ["step_output_source"], {
description: "Rendered markdown content.",
}),
option("context.viewer_id", "Viewer ID", "runtime_context", ["step_input"], {
availability: "conditional",
reason: "Available when the selected step runs in a viewer frame.",
}),
option("output.report", "Final report", "workflow_output", ["workflow_output"]),
option("input.customer.name", "Customer name", "workflow_input", ["step_input"]),
];
describe("AuthoringPathPicker", () => {
it("groups options by semantic origin and shows canonical paths secondarily", () => {
render(
<AuthoringPathPicker
label="Source path"
onChange={vi.fn()}
options={options}
uses="step_input"
value=""
/>,
);
expect(screen.getByRole("group", { name: "Workflow input" })).toBeInTheDocument();
expect(screen.getByRole("group", { name: "State" })).toBeInTheDocument();
expect(screen.getByRole("group", { name: "Step output" })).toBeInTheDocument();
expect(screen.getByRole("group", { name: "Runtime context" })).toBeInTheDocument();
expect(screen.getByText("input.title")).toBeInTheDocument();
expect(screen.getByText("Title")).toBeInTheDocument();
});
it("searches labels, canonical paths, and descriptions", async () => {
const user = userEvent.setup();
render(
<AuthoringPathPicker
label="Source path"
onChange={vi.fn()}
options={options}
uses="step_input"
value=""
/>,
);
await user.type(screen.getByRole("searchbox", { name: "Search Source path" }), "rendered");
expect(screen.getByRole("button", { name: /Markdown/ })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Title/ })).not.toBeInTheDocument();
});
it("explains conditional options and hides an empty runtime context group", () => {
const { rerender } = render(
<AuthoringPathPicker
label="Source path"
onChange={vi.fn()}
options={options}
uses="step_input"
value=""
/>,
);
expect(
screen.getByText("Available when the selected step runs in a viewer frame."),
).toBeInTheDocument();
rerender(
<AuthoringPathPicker
label="Source path"
onChange={vi.fn()}
options={options.filter((entry) => entry.origin !== "runtime_context")}
uses="step_input"
value=""
/>,
);
expect(screen.queryByRole("group", { name: "Runtime context" })).not.toBeInTheDocument();
});
it("disables incompatible entries and emits only a canonical path", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
<AuthoringPathPicker
label="Source path"
onChange={onChange}
options={options}
uses="step_input"
value=""
/>,
);
const incompatible = screen.getByRole("button", { name: /Final report/ });
expect(incompatible).toBeDisabled();
await user.click(incompatible);
await user.click(screen.getByRole("button", { name: /Title/ }));
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith("input.title");
});
it("keeps nested choices keyboard reachable and preserves normal choices in Advanced mode", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
<AuthoringPathPicker
allowCustom
label="Source path"
onChange={onChange}
options={options}
uses="step_input"
value=""
/>,
);
const nested = screen.getByRole("button", { name: /Customer name/ });
nested.focus();
await user.keyboard("{Enter}");
expect(onChange).toHaveBeenCalledWith("input.customer.name");
await user.click(screen.getByText("Advanced"));
expect(screen.getByRole("textbox", { name: "Custom Source path" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Title/ })).toBeInTheDocument();
await user.clear(screen.getByRole("textbox", { name: "Custom Source path" }));
await user.type(screen.getByRole("textbox", { name: "Custom Source path" }), "context.future");
expect(onChange).toHaveBeenLastCalledWith("context.future");
});
});
@@ -0,0 +1,135 @@
import { useEffect, useId, useState } from "react";
import type {
AuthoringPathOption,
AuthoringPathOrigin,
AuthoringPathUse,
} from "../domain/authoring-contract-models.js";
export type AuthoringPathPickerProps = {
readonly options: ReadonlyArray<AuthoringPathOption>;
readonly uses: AuthoringPathUse | ReadonlyArray<AuthoringPathUse>;
readonly value: string;
readonly onChange: (value: string) => void;
readonly label: string;
readonly allowCustom?: boolean;
};
type OptionGroup = {
readonly origin: AuthoringPathOrigin;
readonly label: string;
};
const OPTION_GROUPS: ReadonlyArray<OptionGroup> = [
{ origin: "workflow_input", label: "Workflow input" },
{ origin: "workflow_state", label: "State" },
{ origin: "step_output", label: "Step output" },
{ origin: "runtime_context", label: "Runtime context" },
{ origin: "workflow_output", label: "Workflow output" },
{ origin: "step_input", label: "Step input" },
];
const safeId = (value: string): string => value.replaceAll(/[^a-zA-Z0-9_-]/g, "-");
const optionText = (option: AuthoringPathOption): string =>
[option.label, option.path, option.description].filter(Boolean).join(" ").toLocaleLowerCase();
const normalizedUses = (
uses: AuthoringPathUse | ReadonlyArray<AuthoringPathUse>,
): ReadonlySet<AuthoringPathUse> => new Set(Array.isArray(uses) ? uses : [uses]);
export const AuthoringPathPicker = ({
options,
uses,
value,
onChange,
label,
allowCustom = false,
}: AuthoringPathPickerProps) => {
const id = safeId(useId());
const searchId = `${id}-search`;
const customId = `${id}-custom`;
const [search, setSearch] = useState("");
const [customValue, setCustomValue] = useState(value);
const requestedUses = normalizedUses(uses);
const normalizedSearch = search.trim().toLocaleLowerCase();
useEffect(() => {
setCustomValue(value);
}, [value]);
const visibleOptions = options.filter((option) =>
normalizedSearch === "" || optionText(option).includes(normalizedSearch),
);
return (
<section aria-labelledby={`${id}-heading`} className="authoring-path-picker">
<h3 id={`${id}-heading`}>{label}</h3>
<label htmlFor={searchId}>Search {label}</label>
<input
id={searchId}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search labels or paths"
type="search"
value={search}
/>
<div aria-label={`${label} options`} className="authoring-path-picker__options">
{OPTION_GROUPS.map((group) => {
const groupOptions = visibleOptions.filter((option) => option.origin === group.origin);
if (groupOptions.length === 0) return null;
return (
<fieldset className="authoring-path-picker__group" key={group.origin}>
<legend>{group.label}</legend>
<div className="authoring-path-picker__list">
{groupOptions.map((option) => {
const compatible = option.uses.some((use) => requestedUses.has(use));
const reasonId = `${id}-${safeId(option.path)}-reason`;
return (
<button
aria-describedby={
option.reason !== undefined || !compatible ? reasonId : undefined
}
aria-pressed={option.path === value}
className="authoring-path-picker__option"
disabled={!compatible}
key={option.path}
onClick={() => {
setCustomValue(option.path);
onChange(option.path);
}}
type="button"
>
<strong>{option.label}</strong>
<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>}
</button>
);
})}
</div>
</fieldset>
);
})}
{visibleOptions.length === 0 && <p>No matching paths.</p>}
</div>
{allowCustom && (
<details className="authoring-path-picker__advanced">
<summary>Advanced</summary>
<label htmlFor={customId}>Custom {label}</label>
<input
id={customId}
onChange={(event) => {
setCustomValue(event.target.value);
onChange(event.target.value);
}}
type="text"
value={customValue}
/>
</details>
)}
</section>
);
};
@@ -0,0 +1,196 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ConnectionState } from "../../app/state.js";
import { useConsoleWorkspace } from "../context.js";
import {
createAuthoringContractClient,
type AuthoringContractClient,
} from "../domain/authoring-contract-client.js";
import type {
AuthoringContractInventory,
AuthoringPathOption,
} from "../domain/authoring-contract-models.js";
import type { ConsoleReadExecutor } from "../domain/read-executor.js";
import { useAuthoringContract } from "./useAuthoringContract.js";
vi.mock("../context.js", () => ({
useConsoleWorkspace: vi.fn(),
}));
vi.mock("../domain/authoring-contract-client.js", async () => {
const actual = await vi.importActual<typeof import("../domain/authoring-contract-client.js")>(
"../domain/authoring-contract-client.js",
);
return { ...actual, createAuthoringContractClient: vi.fn() };
});
const mockedUseConsoleWorkspace = vi.mocked(useConsoleWorkspace);
const mockedCreateAuthoringContractClient = vi.mocked(createAuthoringContractClient);
const connectedState = {
phase: "connected",
connectedTarget: "http://workflow.example/rpc",
} as ConnectionState;
const disconnectedState = {
phase: "not_configured",
connectedTarget: null,
} as ConnectionState;
const option: AuthoringPathOption = {
path: "input.title",
label: "Title",
origin: "workflow_input",
schema: { type: "string" },
required: true,
availability: "available",
uses: ["step_input"],
};
const inventory = (revision: number, selectedStepId: string | null): AuthoringContractInventory => ({
workspaceId: "draft-report",
revision,
selectedStepId,
readableSources: [option],
stepInputTargets: [],
stepOutputSources: [],
stateTargets: [],
workflowOutputTargets: [],
entrySteps: [],
workflowOutcomes: ["ok"],
warnings: [],
});
const deferred = <T,>() => {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
};
const client = {
inspect: vi.fn<AuthoringContractClient["inspect"]>(),
} satisfies AuthoringContractClient;
const readExecutor = {} as ConsoleReadExecutor;
beforeEach(() => {
client.inspect.mockReset();
mockedCreateAuthoringContractClient.mockReset();
mockedCreateAuthoringContractClient.mockReturnValue(client);
mockedUseConsoleWorkspace.mockReturnValue({
connection: connectedState,
connectedTarget: connectedState.connectedTarget,
recordEvidence: vi.fn(),
readExecutor,
writeExecutor: null,
});
});
describe("useAuthoringContract", () => {
it("stays disconnected without a read executor", () => {
mockedUseConsoleWorkspace.mockReturnValue({
connection: disconnectedState,
connectedTarget: null,
recordEvidence: vi.fn(),
readExecutor: null,
writeExecutor: null,
});
const { result } = renderHook(() =>
useAuthoringContract({ workspaceId: "draft-report", revision: 7, selectedStepId: null }),
);
expect(result.current.phase).toBe("disconnected");
expect(client.inspect).not.toHaveBeenCalled();
});
it("loads the contract projection with a null selected step", async () => {
client.inspect.mockResolvedValue(inventory(7, null));
const { result } = renderHook(() =>
useAuthoringContract({ workspaceId: "draft-report", revision: 7, selectedStepId: null }),
);
await waitFor(() => expect(result.current.phase).toBe("ready"));
expect(client.inspect).toHaveBeenCalledWith({
workspaceId: "draft-report",
revision: 7,
selectedStepId: null,
});
expect(result.current.inventory?.selectedStepId).toBeNull();
});
it("passes the executable step id and ignores a stale selection response", async () => {
const first = deferred<AuthoringContractInventory>();
const second = deferred<AuthoringContractInventory>();
client.inspect.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
const { result, rerender } = renderHook(
({ selectedStepId }: { readonly selectedStepId: string | null }) =>
useAuthoringContract({ workspaceId: "draft-report", revision: 7, selectedStepId }),
{ initialProps: { selectedStepId: "read" } },
);
await waitFor(() => expect(client.inspect).toHaveBeenCalledTimes(1));
rerender({ selectedStepId: "render" });
await waitFor(() => expect(client.inspect).toHaveBeenCalledTimes(2));
first.resolve(inventory(7, "read"));
second.resolve(inventory(7, "render"));
await waitFor(() => expect(result.current.phase).toBe("ready"));
expect(result.current.inventory?.selectedStepId).toBe("render");
expect(client.inspect).toHaveBeenLastCalledWith({
workspaceId: "draft-report",
revision: 7,
selectedStepId: "render",
});
});
it("retains the last matching inventory when a refresh fails", async () => {
client.inspect
.mockResolvedValueOnce(inventory(7, "read"))
.mockRejectedValueOnce(new Error("inspection failed"));
const { result } = renderHook(() =>
useAuthoringContract({ workspaceId: "draft-report", revision: 7, selectedStepId: "read" }),
);
await waitFor(() => expect(result.current.phase).toBe("ready"));
act(() => result.current.refresh());
await waitFor(() => expect(result.current.phase).toBe("error"));
expect(result.current.inventory?.selectedStepId).toBe("read");
expect(result.current.message).toBe("inspection failed");
});
it("reloads when the revision changes and on manual refresh", async () => {
client.inspect
.mockResolvedValueOnce(inventory(7, "read"))
.mockResolvedValueOnce(inventory(8, "read"))
.mockResolvedValueOnce(inventory(8, "read"));
const { result, rerender } = renderHook(
({ revision }: { readonly revision: number }) =>
useAuthoringContract({ workspaceId: "draft-report", revision, selectedStepId: "read" }),
{ initialProps: { revision: 7 } },
);
await waitFor(() => expect(result.current.inventory?.revision).toBe(7));
rerender({ revision: 8 });
await waitFor(() => expect(result.current.inventory?.revision).toBe(8));
act(() => result.current.refresh());
await waitFor(() => expect(client.inspect).toHaveBeenCalledTimes(3));
expect(client.inspect).toHaveBeenLastCalledWith({
workspaceId: "draft-report",
revision: 8,
selectedStepId: "read",
});
});
});
@@ -0,0 +1,169 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useConsoleWorkspace } from "../context.js";
import {
createAuthoringContractClient,
type AuthoringContractClient,
} from "../domain/authoring-contract-client.js";
import type { AuthoringContractInventory } from "../domain/authoring-contract-models.js";
import type { ConsoleReadExecutor } from "../domain/read-executor.js";
export type AuthoringContractPhase =
| "disconnected"
| "idle"
| "loading"
| "ready"
| "error";
export type UseAuthoringContractOptions = {
readonly workspaceId: string | null;
readonly revision: number | null;
readonly selectedStepId?: string | null;
};
export type AuthoringContractController = {
readonly phase: AuthoringContractPhase;
readonly inventory: AuthoringContractInventory | null;
readonly message: string | null;
readonly refresh: () => void;
};
type RequestIdentity = {
readonly readExecutor: ConsoleReadExecutor;
readonly connectedTarget: string;
readonly workspaceId: string;
readonly revision: number;
readonly selectedStepId: string | null;
};
type StoredInventory = {
readonly request: RequestIdentity;
readonly inventory: AuthoringContractInventory;
};
type AuthoringContractState = {
readonly phase: AuthoringContractPhase;
readonly stored: StoredInventory | null;
readonly message: string | null;
};
const initialState: AuthoringContractState = {
phase: "disconnected",
stored: null,
message: null,
};
const errorMessage = (error: unknown): string =>
error instanceof Error ? error.message : String(error);
const sameRequest = (left: RequestIdentity, right: RequestIdentity): boolean =>
left.readExecutor === right.readExecutor &&
left.connectedTarget === right.connectedTarget &&
left.workspaceId === right.workspaceId &&
left.revision === right.revision &&
left.selectedStepId === right.selectedStepId;
export const useAuthoringContract = ({
workspaceId,
revision,
selectedStepId,
}: UseAuthoringContractOptions): AuthoringContractController => {
const { connectedTarget, readExecutor } = useConsoleWorkspace();
const client = useMemo<AuthoringContractClient | null>(
() => (readExecutor === null ? null : createAuthoringContractClient(readExecutor)),
[readExecutor],
);
const request = useMemo<RequestIdentity | null>(
() =>
readExecutor !== null &&
connectedTarget !== null &&
workspaceId !== null &&
revision !== null
? {
readExecutor,
connectedTarget,
workspaceId,
revision,
selectedStepId: selectedStepId ?? null,
}
: null,
[connectedTarget, readExecutor, revision, selectedStepId, workspaceId],
);
const [state, setState] = useState<AuthoringContractState>(initialState);
const generationRef = useRef(0);
const inspect = useCallback(
(): void => {
if (client === null || request === null) return;
const generation = ++generationRef.current;
const requested = request;
setState((current) => ({
phase: "loading",
stored:
current.stored !== null && sameRequest(current.stored.request, requested)
? current.stored
: null,
message: null,
}));
void client
.inspect({
workspaceId: requested.workspaceId,
revision: requested.revision,
selectedStepId: requested.selectedStepId,
})
.then((inventory) => {
if (generation !== generationRef.current) return;
setState({
phase: "ready",
stored: { request: requested, inventory },
message: null,
});
})
.catch((error: unknown) => {
if (generation !== generationRef.current) return;
setState((current) => ({
...current,
phase: "error",
message: errorMessage(error),
}));
});
}, [client, request]);
useEffect(() => {
if (request === null || client === null) {
generationRef.current++;
setState({
phase: client === null || connectedTarget === null ? "disconnected" : "idle",
stored: null,
message: null,
});
return;
}
inspect();
}, [client, connectedTarget, inspect, request]);
const refresh = useCallback((): void => {
inspect();
}, [inspect]);
const currentInventory =
request !== null && state.stored !== null && sameRequest(state.stored.request, request)
? state.stored.inventory
: null;
const phase =
request !== null &&
(state.stored === null || !sameRequest(state.stored.request, request))
? "loading"
: request === null
? client === null || connectedTarget === null
? "disconnected"
: "idle"
: state.phase;
return {
phase,
inventory: currentInventory,
message: currentInventory === null && phase === "loading" ? null : state.message,
refresh,
};
};