test: cover draft route freshness integration

This commit is contained in:
lda
2026-08-10 01:48:20 +07:00 Verified
parent ee70397653
commit 1e671ed742
2 changed files with 231 additions and 63 deletions
@@ -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 (