test: cover draft route freshness integration
This commit is contained in:
@@ -1,40 +1,73 @@
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Link, MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import type { ComponentProps } from "react";
|
||||
import { MemoryRouter, Route, Routes, useNavigate } from "react-router-dom";
|
||||
import type { CapabilityDetail } from "../domain/capability-models.js";
|
||||
import type { DraftWorkspace } from "../domain/draft-workspace-models.js";
|
||||
import { useDraftWorkspace } from "./useDraftWorkspace.js";
|
||||
import type { DraftWorkspaceController } from "./useDraftWorkspace.js";
|
||||
import { useConsoleWorkspace } from "../context.js";
|
||||
import type { ConsoleWriteExecutor } from "../domain/write-executor.js";
|
||||
import type { DraftAuthoringClient } from "../domain/draft-authoring-client.js";
|
||||
import { createDraftAuthoringClient } from "../domain/draft-authoring-client.js";
|
||||
import { useAuthoringCapabilityDetail } from "../authoring/useAuthoringCapabilityDetail.js";
|
||||
import { DraftDetailRoute } from "./DraftDetailRoute.js";
|
||||
import { useCapabilityDiscovery } from "./useCapabilityDiscovery.js";
|
||||
import type { CapabilityDiscoveryController } from "./useCapabilityDiscovery.js";
|
||||
import type { DraftWorkspaceController } from "./useDraftWorkspace.js";
|
||||
import { useDraftWorkspace } from "./useDraftWorkspace.js";
|
||||
|
||||
const capture = vi.hoisted(() => ({
|
||||
draftChange: null as ((draft: DraftWorkspace) => void) | null,
|
||||
renderCount: 0,
|
||||
}));
|
||||
|
||||
vi.mock("../authoring/DraftWorkbench.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../authoring/DraftWorkbench.js")>(
|
||||
"../authoring/DraftWorkbench.js",
|
||||
);
|
||||
const RealDraftWorkbench = actual.DraftWorkbench;
|
||||
return {
|
||||
...actual,
|
||||
DraftWorkbench: (props: ComponentProps<typeof RealDraftWorkbench>) => {
|
||||
capture.draftChange = props.onDraftChange ?? null;
|
||||
capture.renderCount += 1;
|
||||
return <RealDraftWorkbench {...props} />;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../context.js", () => ({ useConsoleWorkspace: vi.fn() }));
|
||||
vi.mock("../domain/draft-authoring-client.js", () => ({
|
||||
createDraftAuthoringClient: vi.fn(),
|
||||
}));
|
||||
vi.mock("../authoring/useAuthoringCapabilityDetail.js", () => ({
|
||||
useAuthoringCapabilityDetail: vi.fn(),
|
||||
}));
|
||||
vi.mock("./useCapabilityDiscovery.js", () => ({
|
||||
useCapabilityDiscovery: vi.fn(),
|
||||
}));
|
||||
vi.mock("./useDraftWorkspace.js", () => ({
|
||||
useDraftWorkspace: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../authoring/DraftWorkbench.js", () => ({
|
||||
DraftWorkbench: ({
|
||||
draft,
|
||||
onDraftChange,
|
||||
}: {
|
||||
readonly draft: DraftWorkspace;
|
||||
readonly onDraftChange?: (draft: DraftWorkspace) => void;
|
||||
}) => (
|
||||
<section aria-label="Mock draft workbench">
|
||||
<button
|
||||
onClick={() => onDraftChange?.({ ...draft, revision: 4, status: "valid" })}
|
||||
type="button"
|
||||
>
|
||||
Commit current draft
|
||||
</button>
|
||||
<Link to="/console/drafts/other">Open other workspace</Link>
|
||||
</section>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockedUseConsoleWorkspace = vi.mocked(useConsoleWorkspace);
|
||||
const mockedCreateDraftAuthoringClient = vi.mocked(createDraftAuthoringClient);
|
||||
const mockedUseAuthoringCapabilityDetail = vi.mocked(useAuthoringCapabilityDetail);
|
||||
const mockedUseCapabilityDiscovery = vi.mocked(useCapabilityDiscovery);
|
||||
const mockedUseDraftWorkspace = vi.mocked(useDraftWorkspace);
|
||||
|
||||
let loadedReport: DraftWorkspace;
|
||||
let loadedOther: DraftWorkspace;
|
||||
const detail: CapabilityDetail = {
|
||||
kind: "node_spec",
|
||||
name: "demo.collect",
|
||||
sourceId: "demo",
|
||||
description: "Collect source material.",
|
||||
isAsync: false,
|
||||
outcomes: ["ok"],
|
||||
inputSchema: { type: "object", properties: { title: { type: "string" } } },
|
||||
outputSchema: { type: "object", properties: { text: { type: "string" } } },
|
||||
wrapperHints: {},
|
||||
acceptsContext: false,
|
||||
};
|
||||
|
||||
const workspace = (overrides: Partial<DraftWorkspace> = {}): DraftWorkspace => ({
|
||||
workspaceId: "draft-report",
|
||||
@@ -43,13 +76,33 @@ const workspace = (overrides: Partial<DraftWorkspace> = {}): DraftWorkspace => (
|
||||
status: "invalid",
|
||||
diagnostics: [],
|
||||
summary: { name: "report", start: "collect", stepCount: 1, routeCount: 0, steps: ["collect"] },
|
||||
draft: { nodes: [{ id: "collect" }] },
|
||||
draft: {
|
||||
steps: {
|
||||
collect: {
|
||||
use: "demo.collect",
|
||||
desc: "Collect source material.",
|
||||
retry: 1,
|
||||
timeout_seconds: 30,
|
||||
},
|
||||
},
|
||||
routes: {},
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const controller = (selected: DraftWorkspace | null): DraftWorkspaceController => ({
|
||||
const otherWorkspace = workspace({
|
||||
workspaceId: "other",
|
||||
title: "Other",
|
||||
revision: 9,
|
||||
status: "valid",
|
||||
});
|
||||
|
||||
const controller = (
|
||||
selected: DraftWorkspace | null,
|
||||
detailPhase: DraftWorkspaceController["detailPhase"],
|
||||
): DraftWorkspaceController => ({
|
||||
listPhase: "ready",
|
||||
detailPhase: selected === null ? "loading" : "ready",
|
||||
detailPhase,
|
||||
items: [],
|
||||
selected,
|
||||
listMessage: null,
|
||||
@@ -57,22 +110,94 @@ const controller = (selected: DraftWorkspace | null): DraftWorkspaceController =
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
|
||||
const renderRoute = () => render(
|
||||
const discoveryController = (): CapabilityDiscoveryController => ({
|
||||
phase: "ready",
|
||||
query: "",
|
||||
sourceId: "",
|
||||
items: [],
|
||||
selected: null,
|
||||
nextCursor: null,
|
||||
message: null,
|
||||
setQuery: vi.fn(),
|
||||
setSourceId: vi.fn(),
|
||||
search: vi.fn(),
|
||||
loadMore: vi.fn(),
|
||||
inspect: vi.fn(),
|
||||
});
|
||||
|
||||
const authoringClient: DraftAuthoringClient = {
|
||||
createEmpty: vi.fn(),
|
||||
createFromCapability: vi.fn(),
|
||||
addCapabilityStep: vi.fn(),
|
||||
updateCapabilityStep: vi.fn(),
|
||||
setStepInputBindings: vi.fn(),
|
||||
setStepOutputBindings: vi.fn(),
|
||||
setRoute: vi.fn(),
|
||||
validate: vi.fn(),
|
||||
};
|
||||
|
||||
const writeExecutor = { run: vi.fn() } as ConsoleWriteExecutor;
|
||||
let loadedReport: DraftWorkspace;
|
||||
let loaderPhase: DraftWorkspaceController["detailPhase"];
|
||||
|
||||
const NavigateToOtherWorkspace = () => {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<button type="button" onClick={() => navigate("/console/drafts/other")}>
|
||||
Navigate to other workspace
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const routeElement = () => (
|
||||
<MemoryRouter initialEntries={["/console/drafts/draft-report"]}>
|
||||
<Routes>
|
||||
<Route path="/console/drafts/:workspaceId" element={<DraftDetailRoute />} />
|
||||
<Route
|
||||
path="/console/drafts/:workspaceId"
|
||||
element={
|
||||
<>
|
||||
<DraftDetailRoute />
|
||||
<NavigateToOtherWorkspace />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
capture.draftChange = null;
|
||||
capture.renderCount = 0;
|
||||
loadedReport = workspace();
|
||||
loadedOther = workspace({ workspaceId: "other", title: "Other", revision: 9, status: "valid" });
|
||||
mockedUseDraftWorkspace.mockImplementation((workspaceId) => controller(
|
||||
workspaceId === "draft-report"
|
||||
? loadedReport
|
||||
: loadedOther,
|
||||
));
|
||||
loaderPhase = "ready";
|
||||
mockedUseConsoleWorkspace.mockReturnValue({
|
||||
connection: {
|
||||
phase: "connected",
|
||||
draftTarget: "server-a",
|
||||
connectedTarget: "server-a",
|
||||
serverStatus: "ok",
|
||||
storeRoot: "/tmp",
|
||||
durationMs: 1,
|
||||
message: null,
|
||||
evidence: [],
|
||||
},
|
||||
connectedTarget: "server-a",
|
||||
recordEvidence: vi.fn(),
|
||||
readExecutor: writeExecutor,
|
||||
writeExecutor,
|
||||
});
|
||||
mockedCreateDraftAuthoringClient.mockReturnValue(authoringClient);
|
||||
mockedUseAuthoringCapabilityDetail.mockReturnValue({
|
||||
phase: "ready",
|
||||
detail,
|
||||
message: null,
|
||||
});
|
||||
mockedUseCapabilityDiscovery.mockReturnValue(discoveryController());
|
||||
mockedUseDraftWorkspace.mockImplementation((workspaceId) => {
|
||||
if (loaderPhase !== "ready") return controller(null, loaderPhase);
|
||||
return controller(workspaceId === "draft-report" ? loadedReport : otherWorkspace, "ready");
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -81,31 +206,49 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("DraftDetailRoute authoring freshness", () => {
|
||||
it("reflects committed workbench state, accepts loader refreshes, and clears it across workspaces", async () => {
|
||||
it("uses real controller mutations, loader replacement, and stale-callback rejection without looping", async () => {
|
||||
const user = userEvent.setup();
|
||||
const route = renderRoute();
|
||||
const committed = workspace({ revision: 2, status: "valid" });
|
||||
vi.mocked(authoringClient.updateCapabilityStep).mockResolvedValueOnce(committed);
|
||||
const view = render(routeElement());
|
||||
const header = (): HTMLElement => document.querySelector(".draft-detail__header") as HTMLElement;
|
||||
|
||||
expect(screen.getByText("Revision 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Invalid")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Commit current draft" }));
|
||||
expect(screen.getByText("Revision 4")).toBeInTheDocument();
|
||||
const node = document.querySelector('[data-node-id="collect"]');
|
||||
expect(node).not.toBeNull();
|
||||
fireEvent.click(node as HTMLElement);
|
||||
await waitFor(() => expect(screen.getByRole("heading", { name: "collect" })).toBeInTheDocument());
|
||||
const staleCallback = capture.draftChange;
|
||||
expect(staleCallback).not.toBeNull();
|
||||
|
||||
const retry = screen.getByRole("spinbutton", { name: "Retry" });
|
||||
await user.clear(retry);
|
||||
await user.type(retry, "2");
|
||||
await user.click(screen.getByRole("button", { name: "Save setup" }));
|
||||
await waitFor(() => expect(within(header()).getByText("Revision 2")).toBeInTheDocument());
|
||||
expect(screen.getByText("Valid")).toBeInTheDocument();
|
||||
|
||||
loadedReport = workspace({ revision: 7, status: "invalid" });
|
||||
route.rerender(
|
||||
<MemoryRouter initialEntries={["/console/drafts/draft-report"]}>
|
||||
<Routes>
|
||||
<Route path="/console/drafts/:workspaceId" element={<DraftDetailRoute />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
expect(authoringClient.updateCapabilityStep).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ workspaceId: "draft-report", revision: 1, stepId: "collect" }),
|
||||
);
|
||||
await waitFor(() => expect(screen.getByText("Revision 7")).toBeInTheDocument());
|
||||
expect(screen.getByText("Invalid")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("link", { name: "Open other workspace" }));
|
||||
expect(screen.getByRole("heading", { name: "Other" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Revision 9")).toBeInTheDocument();
|
||||
expect(screen.getByText("Valid")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Revision 4")).toBeNull();
|
||||
loaderPhase = "loading";
|
||||
view.rerender(routeElement());
|
||||
expect(screen.getByText("Loading draft workspace...")).toBeInTheDocument();
|
||||
loaderPhase = "ready";
|
||||
loadedReport = workspace({ revision: 3, status: "invalid" });
|
||||
view.rerender(routeElement());
|
||||
await waitFor(() => expect(within(header()).getByText("Revision 3")).toBeInTheDocument());
|
||||
expect(within(header()).getByText("Invalid")).toBeInTheDocument();
|
||||
|
||||
loaderPhase = "disconnected";
|
||||
view.rerender(routeElement());
|
||||
expect(screen.getByText("Connect a workflow server to view this draft.")).toBeInTheDocument();
|
||||
loaderPhase = "ready";
|
||||
await user.click(screen.getByRole("button", { name: "Navigate to other workspace" }));
|
||||
await waitFor(() => expect(screen.getByRole("heading", { name: "Other" })).toBeInTheDocument());
|
||||
expect(within(header()).getByText("Revision 9")).toBeInTheDocument();
|
||||
staleCallback?.({ ...committed, revision: 99, status: "valid" });
|
||||
expect(within(header()).getByText("Revision 9")).toBeInTheDocument();
|
||||
expect(within(header()).queryByText("Revision 99")).toBeNull();
|
||||
expect(capture.renderCount).toBeLessThan(20);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Link, useParams, useSearchParams } from "react-router-dom";
|
||||
import type {
|
||||
DraftWorkspace,
|
||||
@@ -33,13 +33,35 @@ export const DraftDetailRoute = ({
|
||||
const draft =
|
||||
drafts.selected?.workspaceId === workspaceId ? drafts.selected : null;
|
||||
const [authoringDraft, setAuthoringDraft] = useState<DraftWorkspace | null>(null);
|
||||
const loaderSourceRef = useRef<{
|
||||
readonly draft: DraftWorkspace | null;
|
||||
readonly detailPhase: typeof drafts.detailPhase;
|
||||
readonly workspaceId: string | null;
|
||||
} | null>(null);
|
||||
const loaderGenerationRef = useRef(0);
|
||||
const loaderSource = {
|
||||
draft,
|
||||
detailPhase: drafts.detailPhase,
|
||||
workspaceId,
|
||||
};
|
||||
const previousLoaderSource = loaderSourceRef.current;
|
||||
if (
|
||||
previousLoaderSource === null ||
|
||||
previousLoaderSource.draft !== loaderSource.draft ||
|
||||
previousLoaderSource.detailPhase !== loaderSource.detailPhase ||
|
||||
previousLoaderSource.workspaceId !== loaderSource.workspaceId
|
||||
) {
|
||||
loaderSourceRef.current = loaderSource;
|
||||
loaderGenerationRef.current += 1;
|
||||
}
|
||||
const callbackGeneration = loaderGenerationRef.current;
|
||||
|
||||
useEffect(() => {
|
||||
setAuthoringDraft(draft);
|
||||
}, [draft, drafts.detailPhase, workspaceId]);
|
||||
|
||||
// A successful mutation is displayed immediately; a later loader snapshot
|
||||
// replaces it, and workspace ids prevent an old callback crossing routes.
|
||||
// replaces it, and the generation prevents an old callback crossing freshness boundaries.
|
||||
const displayedDraft =
|
||||
draft === null
|
||||
? null
|
||||
@@ -48,9 +70,12 @@ export const DraftDetailRoute = ({
|
||||
: draft;
|
||||
const handleDraftChange = useCallback(
|
||||
(nextDraft: DraftWorkspace): void => {
|
||||
if (nextDraft.workspaceId === workspaceId) setAuthoringDraft(nextDraft);
|
||||
if (
|
||||
callbackGeneration === loaderGenerationRef.current &&
|
||||
nextDraft.workspaceId === workspaceId
|
||||
) setAuthoringDraft(nextDraft);
|
||||
},
|
||||
[workspaceId],
|
||||
[callbackGeneration, workspaceId],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user