feat(FN-3016): research hardening, docker node onboarding, planning model s
Merged branch delivers Docker node onboarding with a new dedicated modal for guided setup, auto-installs the dependency graph plugin in CLI daemon and dashboard processes, hardens the research view with broader test coverage and a stuck-submit fix, plumbs planning draft text and model selection end- Fusion-Task-Id: FN-3016
This commit is contained in:
@@ -208,6 +208,7 @@ export function ResearchView({ projectId, addToast, onOpenSettings, readinessVer
|
||||
try {
|
||||
const providers = selectedProviders.filter((provider) => isProviderEnabled(provider));
|
||||
if (providers.length === 0) {
|
||||
setSubmitting(false);
|
||||
addToast?.("No enabled research sources are available for this project.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -252,6 +252,16 @@ vi.mock("../../components/AgentsView", () => ({
|
||||
AgentsView: () => <div className="agents-view">Agents view</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../../components/ResearchView", () => ({
|
||||
ResearchView: ({ addToast }: { addToast?: (message: string, type?: "success" | "error" | "info") => void }) => (
|
||||
<div data-testid="research-view">
|
||||
<h2>Research</h2>
|
||||
<p data-testid="research-status">completed</p>
|
||||
<button type="button" onClick={() => addToast?.("Task created from research", "success")}>Create Task</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../../components/TodoView", () => ({
|
||||
TodoView: ({ onPlanningMode }: { onPlanningMode?: (initialPlan: string) => void }) => (
|
||||
<div className="todo-view" data-testid="todo-view">
|
||||
@@ -1344,6 +1354,55 @@ describe("App engine pause (soft pause)", () => {
|
||||
});
|
||||
|
||||
describe("App view switching", () => {
|
||||
it("opens research view from overflow and persists view selection", async () => {
|
||||
localStorage.setItem("kb-dashboard-view-mode", "project");
|
||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
experimentalFeatures: {
|
||||
...defaultSettings.experimentalFeatures,
|
||||
researchView: true,
|
||||
},
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
|
||||
fireEvent.click(await screen.findByTestId("view-overflow-research"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("research-view")).toBeInTheDocument();
|
||||
expect(localStorage.getItem(taskViewStorageKey())).toBe("research");
|
||||
});
|
||||
|
||||
localStorage.removeItem("kb-dashboard-view-mode");
|
||||
localStorage.removeItem(taskViewStorageKey());
|
||||
});
|
||||
|
||||
it("falls back to board when research view is feature-disabled", async () => {
|
||||
localStorage.setItem("kb-dashboard-view-mode", "project");
|
||||
localStorage.setItem(taskViewStorageKey(), "research");
|
||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
experimentalFeatures: {
|
||||
...defaultSettings.experimentalFeatures,
|
||||
researchView: false,
|
||||
},
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector(".board")).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByTestId("research-view")).not.toBeInTheDocument();
|
||||
|
||||
localStorage.removeItem("kb-dashboard-view-mode");
|
||||
localStorage.removeItem(taskViewStorageKey());
|
||||
});
|
||||
it("renders Board view by default", async () => {
|
||||
// Set project mode so board view is available
|
||||
localStorage.setItem("kb-dashboard-view-mode", "project");
|
||||
|
||||
@@ -120,11 +120,18 @@ describe("ResearchView", () => {
|
||||
expect(await screen.findByTestId("research-state-empty")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders selected run details", async () => {
|
||||
it("renders selected run details, citations, and history", async () => {
|
||||
mockUseResearch.mockReturnValue({
|
||||
...baseHookValue,
|
||||
runs: [{ id: "RR-1", title: "t", query: "q", status: "running" }],
|
||||
selectedRun: { id: "RR-1", title: "t", query: "q", status: "running", events: [], results: { summary: "Summary", findings: [], citations: [] } },
|
||||
selectedRun: {
|
||||
id: "RR-1",
|
||||
title: "t",
|
||||
query: "q",
|
||||
status: "running",
|
||||
events: [{ id: "evt-1", message: "Started" }],
|
||||
results: { summary: "Summary", findings: [], citations: ["https://example.com"] },
|
||||
},
|
||||
selectedRunId: "RR-1",
|
||||
statusCounts: { pending: 0, running: 1, completed: 0, failed: 0, cancelled: 0 },
|
||||
});
|
||||
@@ -132,6 +139,9 @@ describe("ResearchView", () => {
|
||||
|
||||
render(<ResearchView projectId="p1" />);
|
||||
expect(await screen.findByTestId("research-state-results")).toHaveTextContent("Summary");
|
||||
expect(screen.getByRole("link", { name: "https://example.com" })).toHaveAttribute("href", "https://example.com");
|
||||
fireEvent.click(screen.getByText("Run history"));
|
||||
expect(screen.getByText("Started")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("triggers lifecycle/task/export actions", async () => {
|
||||
@@ -180,6 +190,82 @@ describe("ResearchView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("triggers enrich-task action from finding modal", async () => {
|
||||
const attachRunToTask = vi.fn().mockResolvedValue({});
|
||||
mockUseResearch.mockReturnValue({
|
||||
...baseHookValue,
|
||||
runs: [{ id: "RR-1", title: "t", query: "q", status: "pending" }],
|
||||
selectedRun: {
|
||||
id: "RR-1",
|
||||
title: "t",
|
||||
query: "q",
|
||||
status: "pending",
|
||||
events: [],
|
||||
results: { summary: "Summary", findings: [{ id: "finding-1", heading: "Finding", content: "Impact." }], citations: [] },
|
||||
},
|
||||
selectedRunId: "RR-1",
|
||||
attachRunToTask,
|
||||
});
|
||||
mockFetchAuthStatus.mockResolvedValue({ providers: [{ id: "openrouter", type: "api_key", authenticated: true }] });
|
||||
|
||||
render(<ResearchView projectId="p1" />);
|
||||
fireEvent.click((await screen.findAllByText("Enrich Task"))[0]);
|
||||
|
||||
const enrichDialog = await screen.findByRole("dialog");
|
||||
const targetInput = within(enrichDialog).getByRole("combobox", { name: "Target task" });
|
||||
fireEvent.change(targetInput, { target: { value: "FN-1" } });
|
||||
fireEvent.click(within(enrichDialog).getByRole("button", { name: "Enrich Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(attachRunToTask).toHaveBeenCalledWith("RR-1", "FN-1", "finding-1", false);
|
||||
});
|
||||
});
|
||||
|
||||
it("disables create-run button while submitting", async () => {
|
||||
let resolveCreate: ((value: unknown) => void) | undefined;
|
||||
const createRun = vi.fn().mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveCreate = resolve;
|
||||
}),
|
||||
);
|
||||
mockFetchAuthStatus.mockResolvedValue({ providers: [{ id: "openrouter", type: "api_key", authenticated: true }] });
|
||||
mockUseResearch.mockReturnValue({ ...baseHookValue, createRun });
|
||||
|
||||
render(<ResearchView projectId="p1" />);
|
||||
fireEvent.change(await screen.findByLabelText("Query"), { target: { value: "async query" } });
|
||||
const createButton = screen.getByRole("button", { name: /Create Run/i });
|
||||
|
||||
fireEvent.click(createButton);
|
||||
await waitFor(() => expect(createButton).toBeDisabled());
|
||||
|
||||
resolveCreate?.({ run: { id: "RR-2" } });
|
||||
await waitFor(() => expect(createRun).toHaveBeenCalledWith({ query: "async query", providers: ["web-search"] }));
|
||||
});
|
||||
|
||||
it("wires search field and run selection interactions", async () => {
|
||||
const setSearchQuery = vi.fn();
|
||||
const setSelectedRunId = vi.fn();
|
||||
mockFetchAuthStatus.mockResolvedValue({ providers: [{ id: "openrouter", type: "api_key", authenticated: true }] });
|
||||
mockUseResearch.mockReturnValue({
|
||||
...baseHookValue,
|
||||
searchQuery: "",
|
||||
setSearchQuery,
|
||||
setSelectedRunId,
|
||||
runs: [
|
||||
{ id: "RR-1", title: "Alpha", query: "alpha", status: "pending" },
|
||||
{ id: "RR-2", title: "Beta", query: "beta", status: "completed" },
|
||||
],
|
||||
});
|
||||
|
||||
render(<ResearchView projectId="p1" />);
|
||||
fireEvent.change(await screen.findByPlaceholderText("Search runs"), { target: { value: "beta" } });
|
||||
expect(setSearchQuery).toHaveBeenCalledWith("beta");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /RR-2/i }));
|
||||
expect(setSelectedRunId).toHaveBeenCalledWith("RR-2");
|
||||
});
|
||||
|
||||
it("renders unavailable state without interactive workflow controls", async () => {
|
||||
mockUseResearch.mockReturnValue({ ...baseHookValue, availability: { available: false, reason: "disabled" } });
|
||||
render(<ResearchView projectId="p1" />);
|
||||
|
||||
@@ -4,39 +4,39 @@ import { useResearch } from "../useResearch";
|
||||
|
||||
const mockListResearchRuns = vi.fn();
|
||||
const mockGetResearchRun = vi.fn();
|
||||
const mockCreateResearchRun = vi.fn();
|
||||
const mockCancelResearchRun = vi.fn();
|
||||
const mockRetryResearchRun = vi.fn();
|
||||
const mockExportResearchRun = vi.fn();
|
||||
const mockCreateTaskFromResearchRun = vi.fn();
|
||||
const mockAttachResearchRunToTask = vi.fn();
|
||||
const mockSubscribeSse = vi.fn(() => vi.fn());
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
listResearchRuns: (...args: unknown[]) => mockListResearchRuns(...args),
|
||||
getResearchRun: (...args: unknown[]) => mockGetResearchRun(...args),
|
||||
createResearchRun: vi.fn(),
|
||||
cancelResearchRun: vi.fn(),
|
||||
retryResearchRun: vi.fn(),
|
||||
exportResearchRun: vi.fn(),
|
||||
createTaskFromResearchRun: vi.fn(),
|
||||
attachResearchRunToTask: vi.fn(),
|
||||
createResearchRun: (...args: unknown[]) => mockCreateResearchRun(...args),
|
||||
cancelResearchRun: (...args: unknown[]) => mockCancelResearchRun(...args),
|
||||
retryResearchRun: (...args: unknown[]) => mockRetryResearchRun(...args),
|
||||
exportResearchRun: (...args: unknown[]) => mockExportResearchRun(...args),
|
||||
createTaskFromResearchRun: (...args: unknown[]) => mockCreateTaskFromResearchRun(...args),
|
||||
attachResearchRunToTask: (...args: unknown[]) => mockAttachResearchRunToTask(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../sse-bus", () => ({
|
||||
subscribeSse: vi.fn(() => () => {}),
|
||||
subscribeSse: (...args: unknown[]) => mockSubscribeSse(...args),
|
||||
}));
|
||||
|
||||
describe("useResearch", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockListResearchRuns.mockResolvedValue({ runs: [], availability: { available: true } });
|
||||
mockGetResearchRun.mockResolvedValue({ run: { id: "RR-2", title: "t" }, availability: { available: true } });
|
||||
});
|
||||
|
||||
it("loads research runs and availability", async () => {
|
||||
mockListResearchRuns.mockResolvedValue({
|
||||
runs: [
|
||||
{
|
||||
id: "RR-1",
|
||||
query: "query",
|
||||
title: "query",
|
||||
status: "running",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
runs: [{ id: "RR-1", query: "query", title: "query", status: "running", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }],
|
||||
availability: { available: true },
|
||||
});
|
||||
|
||||
@@ -50,9 +50,6 @@ describe("useResearch", () => {
|
||||
});
|
||||
|
||||
it("loads selected run detail", async () => {
|
||||
mockListResearchRuns.mockResolvedValue({ runs: [], availability: { available: true } });
|
||||
mockGetResearchRun.mockResolvedValue({ run: { id: "RR-2", title: "t" }, availability: { available: true } });
|
||||
|
||||
const { result } = renderHook(() => useResearch({ projectId: "p1" }));
|
||||
|
||||
act(() => {
|
||||
@@ -63,4 +60,89 @@ describe("useResearch", () => {
|
||||
expect(mockGetResearchRun).toHaveBeenCalledWith("RR-2", "p1");
|
||||
});
|
||||
});
|
||||
|
||||
it("passes search query to list endpoint after debounce", async () => {
|
||||
const { result } = renderHook(() => useResearch({ projectId: "p1" }));
|
||||
act(() => {
|
||||
result.current.setSearchQuery("llm");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockListResearchRuns).toHaveBeenLastCalledWith({ q: "llm", limit: 100 }, "p1");
|
||||
});
|
||||
});
|
||||
|
||||
it("derives status counts and clears selected run when missing from refreshed list", async () => {
|
||||
mockListResearchRuns
|
||||
.mockResolvedValueOnce({
|
||||
runs: [
|
||||
{ id: "RR-1", query: "a", title: "a", status: "running", createdAt: "", updatedAt: "" },
|
||||
{ id: "RR-2", query: "b", title: "b", status: "failed", createdAt: "", updatedAt: "" },
|
||||
],
|
||||
availability: { available: true },
|
||||
})
|
||||
.mockResolvedValueOnce({ runs: [{ id: "RR-2", query: "b", title: "b", status: "failed", createdAt: "", updatedAt: "" }], availability: { available: true } });
|
||||
|
||||
const { result } = renderHook(() => useResearch({ projectId: "p1" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.statusCounts.running).toBe(1);
|
||||
expect(result.current.statusCounts.failed).toBe(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedRunId("RR-1");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetResearchRun).toHaveBeenCalledWith("RR-1", "p1");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refresh();
|
||||
});
|
||||
|
||||
expect(result.current.selectedRunId).toBeNull();
|
||||
});
|
||||
|
||||
it("wires cancel/retry/export and task actions through API helpers", async () => {
|
||||
mockCancelResearchRun.mockResolvedValue({ run: { id: "RR-1" } });
|
||||
mockRetryResearchRun.mockResolvedValue({ run: { id: "RR-1" } });
|
||||
|
||||
const { result } = renderHook(() => useResearch({ projectId: "p1" }));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.createRun({ query: "q", providers: ["web-search"] });
|
||||
await result.current.cancelRun("RR-1");
|
||||
await result.current.retryRun("RR-1");
|
||||
await result.current.exportRun("RR-1", "markdown");
|
||||
await result.current.createTaskFromRun("RR-1", "Title", "finding-1", "Body", "high", true);
|
||||
await result.current.attachRunToTask("RR-1", "FN-1", "finding-1", true);
|
||||
});
|
||||
|
||||
expect(mockCreateResearchRun).toHaveBeenCalledWith({ query: "q", providers: ["web-search"] }, "p1");
|
||||
expect(mockCancelResearchRun).toHaveBeenCalledWith("RR-1", "p1");
|
||||
expect(mockRetryResearchRun).toHaveBeenCalledWith("RR-1", "p1");
|
||||
expect(mockExportResearchRun).toHaveBeenCalledWith("RR-1", "markdown", "p1");
|
||||
expect(mockCreateTaskFromResearchRun).toHaveBeenCalledWith(
|
||||
"RR-1",
|
||||
{ title: "Title", findingId: "finding-1", description: "Body", priority: "high", attachExport: true },
|
||||
"p1",
|
||||
);
|
||||
expect(mockAttachResearchRunToTask).toHaveBeenCalledWith("RR-1", { taskId: "FN-1", findingId: "finding-1", attachExport: true }, "p1");
|
||||
});
|
||||
|
||||
it("subscribes to research SSE events with project query and reconnect handler", async () => {
|
||||
renderHook(() => useResearch({ projectId: "p1" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSubscribeSse).toHaveBeenCalledWith(
|
||||
"/api/events?projectId=p1",
|
||||
expect.objectContaining({
|
||||
events: expect.objectContaining({ "research:run:created": expect.any(Function) }),
|
||||
onReconnect: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,6 +48,7 @@ function createMockStore(options?: {
|
||||
updateRun: vi.fn(),
|
||||
appendEvent: vi.fn(),
|
||||
addSource: vi.fn(),
|
||||
searchRuns: vi.fn(() => []),
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -86,6 +87,39 @@ describe("research-routes", () => {
|
||||
expect(Array.isArray(response.body.runs)).toBe(true);
|
||||
});
|
||||
|
||||
it("supports run status actions, detail fetch, and export formats", async () => {
|
||||
const store = createMockStore();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(createResearchRouter(store as any));
|
||||
|
||||
const getRun = await performGet(app, "/runs/RR-1");
|
||||
expect(getRun.status).toBe(200);
|
||||
expect(getRun.body.run.id).toBe("RR-1");
|
||||
|
||||
const cancel = await performRequest(app, "POST", "/runs/RR-1/cancel");
|
||||
expect(cancel.status).toBe(200);
|
||||
expect(cancel.body.run.status).toBe("pending");
|
||||
|
||||
const retry = await performRequest(app, "POST", "/runs/RR-1/retry");
|
||||
expect(retry.status).toBe(200);
|
||||
expect(retry.body.run.status).toBe("pending");
|
||||
|
||||
const markdownExport = await performGet(app, "/runs/RR-1/export?format=markdown");
|
||||
expect(markdownExport.status).toBe(200);
|
||||
expect(markdownExport.body.format).toBe("markdown");
|
||||
|
||||
const jsonExport = await performGet(app, "/runs/RR-1/export?format=json");
|
||||
expect(jsonExport.status).toBe(200);
|
||||
expect(jsonExport.body.format).toBe("json");
|
||||
|
||||
const htmlExport = await performGet(app, "/runs/RR-1/export?format=html");
|
||||
expect(htmlExport.status).toBe(200);
|
||||
expect(htmlExport.body.format).toBe("html");
|
||||
|
||||
expect(store.getResearchStore().updateStatus).toHaveBeenCalledWith("RR-1", "cancelled");
|
||||
expect(store.getResearchStore().updateStatus).toHaveBeenCalledWith("RR-1", "pending");
|
||||
});
|
||||
it("creates task from finding with research provenance", async () => {
|
||||
const store = createMockStore();
|
||||
const app = express();
|
||||
@@ -350,4 +384,13 @@ describe("research-routes", () => {
|
||||
expect(response.body.error).toContain("Invalid mime type");
|
||||
});
|
||||
|
||||
it("supports search endpoint", async () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(createResearchRouter(createMockStore() as any));
|
||||
|
||||
const search = await performGet(app, "/search?q=test");
|
||||
expect(search.status).toBe(200);
|
||||
expect(Array.isArray(search.body.runs)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user