feat: hand capabilities into draft authoring

This commit is contained in:
lda
2026-08-09 08:47:03 +07:00 Verified
parent ef773dedb3
commit ee0b77125d
9 changed files with 718 additions and 19 deletions
@@ -1,15 +1,40 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DraftWorkspaceController } from "./useDraftWorkspace.js";
import { useDraftWorkspace } from "./useDraftWorkspace.js";
import type { CapabilityDiscoveryController } from "./useCapabilityDiscovery.js";
import { useCapabilityDiscovery } from "./useCapabilityDiscovery.js";
import { DiscoverRoute } from "./DiscoverRoute.js";
import { useConsoleWorkspace } from "../context.js";
import {
createDraftAuthoringClient,
type DraftAuthoringClient,
} from "../domain/draft-authoring-client.js";
import type { DraftWorkspace } from "../domain/draft-workspace-models.js";
import { initialState } from "../../app/state.js";
vi.mock("./useCapabilityDiscovery.js", () => ({
useCapabilityDiscovery: vi.fn(),
}));
vi.mock("./useDraftWorkspace.js", () => ({
useDraftWorkspace: vi.fn(),
}));
vi.mock("../context.js", () => ({
useConsoleWorkspace: vi.fn(),
}));
vi.mock("../domain/draft-authoring-client.js", () => ({
createDraftAuthoringClient: vi.fn(),
}));
const mockedUseCapabilityDiscovery = vi.mocked(useCapabilityDiscovery);
const mockedUseDraftWorkspace = vi.mocked(useDraftWorkspace);
const mockedUseConsoleWorkspace = vi.mocked(useConsoleWorkspace);
const mockedCreateDraftAuthoringClient = vi.mocked(createDraftAuthoringClient);
const summary = {
kind: "node_spec" as const,
@@ -21,6 +46,32 @@ const summary = {
outputFields: ["documents"],
};
const draft = (workspaceId: string): DraftWorkspace => ({
workspaceId,
revision: 1,
title: "Existing draft",
status: "invalid",
diagnostics: [],
summary: {
name: "existing",
start: null,
stepCount: 0,
routeCount: 0,
steps: [],
},
draft: null,
});
const draftController = (): DraftWorkspaceController => ({
listPhase: "ready",
detailPhase: "idle",
items: [draft("draft-existing")],
selected: null,
listMessage: null,
detailMessage: null,
refresh: vi.fn(),
});
const controller = (
overrides: Partial<CapabilityDiscoveryController> = {},
): CapabilityDiscoveryController => ({
@@ -39,12 +90,47 @@ const controller = (
...overrides,
});
beforeEach(() => mockedUseCapabilityDiscovery.mockReturnValue(controller()));
const authoringClient: DraftAuthoringClient = {
createEmpty: vi.fn(),
createFromCapability: vi.fn(),
addCapabilityStep: vi.fn(),
updateCapabilityStep: vi.fn(),
setRoute: vi.fn(),
validate: vi.fn(),
};
beforeEach(() => {
mockedUseCapabilityDiscovery.mockReturnValue(controller());
mockedUseDraftWorkspace.mockReturnValue(draftController());
mockedUseConsoleWorkspace.mockReturnValue({
connection: initialState(),
connectedTarget: "http://workflow.test/rpc",
recordEvidence: vi.fn(),
readExecutor: null,
writeExecutor: { run: vi.fn() },
});
mockedCreateDraftAuthoringClient.mockReturnValue(authoringClient);
});
afterEach(() => cleanup());
const renderRoute = () =>
render(
<MemoryRouter initialEntries={["/console/discover"]}>
<Routes>
<Route path="/console/discover" element={<DiscoverRoute />} />
<Route path="/console/drafts/:workspaceId" element={<DraftDestination />} />
</Routes>
</MemoryRouter>,
);
const DraftDestination = () => {
const location = useLocation();
return <p>Draft destination: {location.pathname}{location.search}</p>;
};
describe("DiscoverRoute", () => {
it("shows the discovery heading and searchable source-filtered controls", () => {
render(<DiscoverRoute />);
renderRoute();
expect(screen.getByRole("heading", { name: "Discover capabilities" })).toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Search capabilities" })).toBeInTheDocument();
@@ -55,7 +141,7 @@ describe("DiscoverRoute", () => {
it("renders compact capability rows with contract summary fields", async () => {
const inspect = vi.fn();
mockedUseCapabilityDiscovery.mockReturnValue(controller({ inspect }));
render(<DiscoverRoute />);
renderRoute();
expect(screen.getByText("Node spec")).toBeInTheDocument();
expect(screen.getByText("Source: local.documents")).toBeInTheDocument();
@@ -75,7 +161,7 @@ describe("DiscoverRoute", () => {
mockedUseCapabilityDiscovery.mockReturnValue(
controller({ phase, message: phase === "error" ? message : null, items: [] }),
);
render(<DiscoverRoute />);
renderRoute();
expect(screen.getByText(message)).toBeInTheDocument();
});
@@ -93,13 +179,13 @@ describe("DiscoverRoute", () => {
},
}),
);
render(<DiscoverRoute />);
renderRoute();
expect(screen.getByRole("heading", { name: "Input schema" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Output schema" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Wrapper hints" })).toBeInTheDocument();
expect(screen.getAllByText(/"names"/)).toHaveLength(2);
expect(screen.queryByRole("button", { name: /add to draft/i })).toBeNull();
expect(screen.getByRole("button", { name: "Add to draft" })).toBeInTheDocument();
});
it("exposes selected row state and associates the result with its detail", () => {
@@ -115,7 +201,7 @@ describe("DiscoverRoute", () => {
},
}),
);
render(<DiscoverRoute />);
renderRoute();
const row = screen.getByRole("button", { name: /local\.documents\.read/i });
expect(row).toHaveAttribute("aria-pressed", "true");
@@ -131,14 +217,14 @@ describe("DiscoverRoute", () => {
mockedUseCapabilityDiscovery.mockReturnValue(
controller({ nextCursor: "page-2", loadMore }),
);
render(<DiscoverRoute />);
renderRoute();
await userEvent.click(screen.getByRole("button", { name: "Load more capabilities" }));
expect(loadMore).toHaveBeenCalledOnce();
cleanup();
mockedUseCapabilityDiscovery.mockReturnValue(controller());
render(<DiscoverRoute />);
renderRoute();
expect(screen.queryByRole("button", { name: "Load more capabilities" })).toBeNull();
});
@@ -150,4 +236,65 @@ describe("DiscoverRoute", () => {
expect(screen.getByRole("button", { name: "Load more capabilities" })).toBeDisabled();
});
it("hands an inspected capability to an existing draft through the URL", async () => {
const user = userEvent.setup();
mockedUseCapabilityDiscovery.mockReturnValue(
controller({
selected: {
...summary,
isAsync: false,
inputSchema: {},
outputSchema: {},
wrapperHints: {},
acceptsContext: true,
},
}),
);
renderRoute();
await user.click(screen.getByRole("button", { name: "Add to draft" }));
await user.selectOptions(screen.getByRole("combobox", { name: "Existing draft" }), "draft-existing");
await user.click(screen.getByRole("button", { name: "Use existing draft" }));
expect(
await screen.findByText("Draft destination: /console/drafts/draft-existing?capability=local.documents.read"),
).toBeInTheDocument();
});
it("creates a seeded draft and routes by the canonical workspace id", async () => {
const user = userEvent.setup();
const created = draft("canonical-created-id");
vi.mocked(authoringClient.createFromCapability).mockResolvedValue(created);
mockedUseCapabilityDiscovery.mockReturnValue(
controller({
selected: {
...summary,
isAsync: false,
inputSchema: {},
outputSchema: {},
wrapperHints: {},
acceptsContext: true,
},
}),
);
renderRoute();
await user.click(screen.getByRole("button", { name: "Add to draft" }));
await user.type(screen.getByRole("textbox", { name: "Workspace id" }), "requested-id");
await user.type(screen.getByRole("textbox", { name: "Draft name" }), "seeded-report");
await user.click(screen.getByRole("button", { name: "Create seeded draft" }));
expect(authoringClient.createFromCapability).toHaveBeenCalledWith({
workspaceId: "requested-id",
name: "seeded-report",
title: "",
capabilityName: "local.documents.read",
});
expect(
await screen.findByText(
"Draft destination: /console/drafts/canonical-created-id?capability=local.documents.read",
),
).toBeInTheDocument();
});
});
@@ -1,8 +1,10 @@
import { useState } from "react";
import { Boxes, PackageOpen } from "lucide-react";
import type {
CapabilityDetail,
CapabilitySummary,
} from "../domain/capability-models.js";
import { CreateDraftDialog } from "../authoring/CreateDraftDialog.js";
import { useCapabilityDiscovery } from "./useCapabilityDiscovery.js";
const formatKind = (kind: CapabilitySummary["kind"]): string =>
@@ -62,7 +64,13 @@ const CapabilityRow = ({
</li>
);
const DetailView = ({ detail }: { readonly detail: CapabilityDetail }) => (
const DetailView = ({
detail,
onAddToDraft,
}: {
readonly detail: CapabilityDetail;
readonly onAddToDraft: () => void;
}) => (
<section aria-labelledby="capability-detail-heading" className="capability-discovery__detail" id="capability-detail">
<p className="workspace-route-pending__eyebrow">Selected contract</p>
<h2 id="capability-detail-heading">{detail.name}</h2>
@@ -77,11 +85,13 @@ const DetailView = ({ detail }: { readonly detail: CapabilityDetail }) => (
<SchemaBlock heading="Output schema" value={detail.outputSchema} />
<SchemaBlock heading="Wrapper hints" value={detail.wrapperHints} />
</div>
<button onClick={onAddToDraft} type="button">Add to draft</button>
</section>
);
export const DiscoverRoute = () => {
const discovery = useCapabilityDiscovery();
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const isReady = discovery.phase === "ready";
return (
@@ -164,7 +174,10 @@ export const DiscoverRoute = () => {
</section>
{discovery.selected ? (
<DetailView detail={discovery.selected} />
<DetailView
detail={discovery.selected}
onAddToDraft={() => setCreateDialogOpen(true)}
/>
) : (
<section aria-labelledby="capability-detail-empty-heading" className="capability-discovery__detail capability-discovery__detail--empty" id="capability-detail">
<p className="workspace-route-pending__eyebrow">Contract detail</p>
@@ -173,6 +186,12 @@ export const DiscoverRoute = () => {
</section>
)}
</div>
{createDialogOpen && discovery.selected !== null && (
<CreateDraftDialog
capability={discovery.selected}
onClose={() => setCreateDialogOpen(false)}
/>
)}
</div>
);
};
@@ -85,6 +85,13 @@ describe("DraftDetailRoute", () => {
expect(screen.getAllByText("Revision 3")).not.toHaveLength(0);
});
it("uses a capability query only as the workbench's initial browser selection", () => {
renderRoute("draft-report?capability=local.documents.read%2Fv2");
expect(screen.getByRole("heading", { name: "local.documents.read/v2" })).toBeInTheDocument();
expect(screen.getByText("Draft authoring workbench")).toBeInTheDocument();
});
it("lists the start step, step ids, and diagnostics beside the summary", () => {
renderRoute();
@@ -1,8 +1,9 @@
import { Link, useParams } from "react-router-dom";
import { Link, useParams, useSearchParams } from "react-router-dom";
import type {
DraftWorkspace,
} from "../domain/draft-workspace-models.js";
import { DraftWorkbench } from "../authoring/DraftWorkbench.js";
import type { WorkbenchSelection } from "../authoring/authoring-graph.js";
import { useDraftWorkspace } from "./useDraftWorkspace.js";
import { useCapabilityDiscovery } from "./useCapabilityDiscovery.js";
@@ -20,6 +21,12 @@ export const DraftDetailRoute = ({
enableNavigationProtection = false,
}: DraftDetailRouteProps) => {
const { workspaceId = null } = useParams<{ workspaceId: string }>();
const [searchParams] = useSearchParams();
const capabilityName = searchParams.get("capability");
const initialSelection: WorkbenchSelection =
capabilityName !== null && capabilityName.trim() !== ""
? { kind: "capability", qualifiedName: capabilityName }
: { kind: "canvas" };
const drafts = useDraftWorkspace(workspaceId);
const capabilities = useCapabilityDiscovery();
const draft =
@@ -60,6 +67,7 @@ export const DraftDetailRoute = ({
capabilities={capabilities.items}
draft={draft}
enableNavigationProtection={enableNavigationProtection}
initialSelection={initialSelection}
/>
</>
)}
@@ -1,7 +1,14 @@
import { cleanup, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DraftWorkspace } from "../domain/draft-workspace-models.js";
import { initialState } from "../../app/state.js";
import { useConsoleWorkspace } from "../context.js";
import {
createDraftAuthoringClient,
type DraftAuthoringClient,
} from "../domain/draft-authoring-client.js";
import type { DraftWorkspaceController } from "./useDraftWorkspace.js";
import { useDraftWorkspace } from "./useDraftWorkspace.js";
import { DraftIndexRoute } from "./DraftIndexRoute.js";
@@ -10,7 +17,17 @@ vi.mock("./useDraftWorkspace.js", () => ({
useDraftWorkspace: vi.fn(),
}));
vi.mock("../context.js", () => ({
useConsoleWorkspace: vi.fn(),
}));
vi.mock("../domain/draft-authoring-client.js", () => ({
createDraftAuthoringClient: vi.fn(),
}));
const mockedUseDraftWorkspace = vi.mocked(useDraftWorkspace);
const mockedUseConsoleWorkspace = vi.mocked(useConsoleWorkspace);
const mockedCreateDraftAuthoringClient = vi.mocked(createDraftAuthoringClient);
const workspace = (
workspaceId: string,
@@ -45,9 +62,33 @@ const controller = (
...overrides,
});
beforeEach(() => mockedUseDraftWorkspace.mockReturnValue(controller()));
const authoringClient: DraftAuthoringClient = {
createEmpty: vi.fn(),
createFromCapability: vi.fn(),
addCapabilityStep: vi.fn(),
updateCapabilityStep: vi.fn(),
setRoute: vi.fn(),
validate: vi.fn(),
};
beforeEach(() => {
mockedUseDraftWorkspace.mockReturnValue(controller());
mockedUseConsoleWorkspace.mockReturnValue({
connection: initialState(),
connectedTarget: "http://workflow.test/rpc",
recordEvidence: vi.fn(),
readExecutor: null,
writeExecutor: { run: vi.fn() },
});
mockedCreateDraftAuthoringClient.mockReturnValue(authoringClient);
});
afterEach(() => cleanup());
const DraftDestination = () => {
const location = useLocation();
return <p>Draft destination: {location.pathname}{location.search}</p>;
};
describe("DraftIndexRoute", () => {
it("shows the draft heading and a row link owned by each workspace id", () => {
mockedUseDraftWorkspace.mockReturnValue(
@@ -103,4 +144,31 @@ describe("DraftIndexRoute", () => {
expect(screen.getByText(message)).toBeInTheDocument();
});
it("creates a draft from the index and routes by the canonical workspace id", async () => {
const user = userEvent.setup();
const created = workspace("canonical-draft-id", { title: "Created draft" });
vi.mocked(authoringClient.createEmpty).mockResolvedValue(created);
render(
<MemoryRouter initialEntries={["/console/drafts"]}>
<Routes>
<Route path="/console/drafts" element={<DraftIndexRoute />} />
<Route path="/console/drafts/:workspaceId" element={<DraftDestination />} />
</Routes>
</MemoryRouter>,
);
await user.click(screen.getByRole("button", { name: "New draft" }));
await user.type(screen.getByRole("textbox", { name: "Workspace id" }), "requested-draft");
await user.type(screen.getByRole("textbox", { name: "Draft name" }), "report-workflow");
await user.type(screen.getByRole("textbox", { name: "Title" }), "Created draft");
await user.click(screen.getByRole("button", { name: "Create draft" }));
expect(authoringClient.createEmpty).toHaveBeenCalledWith({
workspaceId: "requested-draft",
name: "report-workflow",
title: "Created draft",
});
expect(await screen.findByText("Draft destination: /console/drafts/canonical-draft-id")).toBeInTheDocument();
});
});
@@ -1,5 +1,7 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import type { DraftWorkspace } from "../domain/draft-workspace-models.js";
import { CreateDraftDialog } from "../authoring/CreateDraftDialog.js";
import { useDraftWorkspace } from "./useDraftWorkspace.js";
const titleFor = (workspace: DraftWorkspace): string =>
@@ -29,6 +31,7 @@ const DraftRow = ({ workspace }: { readonly workspace: DraftWorkspace }) => (
export const DraftIndexRoute = () => {
const drafts = useDraftWorkspace(null);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
return (
<div className="draft-workspaces">
@@ -36,9 +39,14 @@ export const DraftIndexRoute = () => {
<p className="workspace-route-pending__eyebrow">Authoring inventory</p>
<h1>Draft workspaces</h1>
<p>Inspect saved workflow drafts without changing their definitions.</p>
<button onClick={drafts.refresh} type="button">
Refresh drafts
</button>
<div className="draft-workspaces__actions">
<button onClick={() => setCreateDialogOpen(true)} type="button">
New draft
</button>
<button onClick={drafts.refresh} type="button">
Refresh drafts
</button>
</div>
</header>
<section aria-labelledby="draft-workspaces-list-heading" className="draft-workspaces__panel">
@@ -85,6 +93,9 @@ export const DraftIndexRoute = () => {
</div>
)}
</section>
{createDialogOpen && (
<CreateDraftDialog capability={null} onClose={() => setCreateDialogOpen(false)} />
)}
</div>
);
};