feat(FN-3368): tighten research view test coverage and resolve dashboard ty
This merge introduces an eval automation domain store with persistence schema and a plugin dashboard view registry (FN-3512/FN-3513), adds scheduled eval batch architecture documentation (FN-3388), and includes substantial test coverage for Research routes and hooks (FN-3368 steps 1-3). The branch a Fusion-Task-Id: FN-3368
This commit is contained in:
@@ -145,8 +145,6 @@ export async function runScheduledEvalBatch(
|
||||
window: {
|
||||
since: windowStartExclusive,
|
||||
until: windowEndInclusive,
|
||||
windowStartExclusive,
|
||||
windowEndInclusive,
|
||||
},
|
||||
metadata: {
|
||||
windowStartExclusive,
|
||||
|
||||
@@ -242,6 +242,12 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
||||
insightExtractionEnabled: false,
|
||||
insightExtractionSchedule: "0 2 * * *",
|
||||
insightExtractionMinIntervalMs: 86_400_000,
|
||||
taskEvaluationEnabled: false,
|
||||
taskEvaluationSchedule: "0 5 * * *",
|
||||
taskEvaluationProvider: undefined,
|
||||
taskEvaluationModelId: undefined,
|
||||
taskEvaluationFollowUpPolicy: "off",
|
||||
taskEvaluationRetention: undefined,
|
||||
memoryEnabled: true,
|
||||
memoryBackendType: "qmd",
|
||||
memoryAutoSummarizeEnabled: false,
|
||||
|
||||
@@ -1712,6 +1712,18 @@ export interface ProjectSettings {
|
||||
testCommand?: string;
|
||||
/** Custom build command for the project (e.g. "pnpm build") */
|
||||
buildCommand?: string;
|
||||
/** Enables automated scheduled evaluation of completed tasks. */
|
||||
taskEvaluationEnabled?: boolean;
|
||||
/** Cron expression for scheduled task evaluation batches. */
|
||||
taskEvaluationSchedule?: string;
|
||||
/** Optional provider override for task evaluation. */
|
||||
taskEvaluationProvider?: string;
|
||||
/** Optional model override for task evaluation. */
|
||||
taskEvaluationModelId?: string;
|
||||
/** Follow-up behavior for evaluation findings. */
|
||||
taskEvaluationFollowUpPolicy?: "off" | "suggest" | "create";
|
||||
/** Number of days to retain evaluation data. */
|
||||
taskEvaluationRetention?: number;
|
||||
/** When true, completed task worktrees are returned to an idle pool instead
|
||||
* of being deleted. New tasks acquire a warm worktree from the pool,
|
||||
* preserving build caches (node_modules, target/, dist/). Default: false. */
|
||||
|
||||
@@ -878,8 +878,12 @@ function AppInner() {
|
||||
projectId: currentProject?.id,
|
||||
tasks: isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks,
|
||||
workflowSteps,
|
||||
openTaskDetail: isMobile ? (task, initialTab) => openDetailTaskWithHistory(task, initialTab) : (task, initialTab) => modalManager.openDetailTask(task, initialTab),
|
||||
renderTaskCard: (task) => (
|
||||
openTaskDetail: isMobile
|
||||
? (task: Task | TaskDetail, initialTab?: Parameters<typeof modalManager.openDetailTask>[1]) =>
|
||||
openDetailTaskWithHistory(task, initialTab)
|
||||
: (task: Task | TaskDetail, initialTab?: Parameters<typeof modalManager.openDetailTask>[1]) =>
|
||||
modalManager.openDetailTask(task, initialTab),
|
||||
renderTaskCard: (task: Task) => (
|
||||
<TaskCard
|
||||
task={task}
|
||||
projectId={currentProject?.id}
|
||||
|
||||
@@ -1453,6 +1453,28 @@ describe("App view switching", () => {
|
||||
localStorage.removeItem(taskViewStorageKey());
|
||||
});
|
||||
|
||||
it("does not expose research navigation when research feature is disabled", async () => {
|
||||
localStorage.setItem("kb-dashboard-view-mode", "project");
|
||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
experimentalFeatures: {
|
||||
...defaultSettings.experimentalFeatures,
|
||||
researchView: false,
|
||||
},
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
|
||||
expect(screen.queryByTestId("view-overflow-research")).not.toBeInTheDocument();
|
||||
|
||||
localStorage.removeItem("kb-dashboard-view-mode");
|
||||
});
|
||||
|
||||
it("initializes research view from persisted task-view when feature-enabled", async () => {
|
||||
localStorage.setItem("kb-dashboard-view-mode", "project");
|
||||
localStorage.setItem(taskViewStorageKey(), "research");
|
||||
|
||||
@@ -201,12 +201,12 @@ describe("ResearchView", () => {
|
||||
|
||||
mockUseResearch.mockReturnValue({
|
||||
...baseHookValue,
|
||||
runs: [{ id: "RR-1", title: "t", query: "q", status: "pending" }],
|
||||
runs: [{ id: "RR-1", title: "t", query: "q", status: "queued" }],
|
||||
selectedRun: {
|
||||
id: "RR-1",
|
||||
title: "t",
|
||||
query: "q",
|
||||
status: "pending",
|
||||
status: "queued",
|
||||
events: [{ id: "E-1", message: "queued" }],
|
||||
results: { summary: "Summary", findings: [{ id: "finding-1", heading: "Finding", content: "Impact." }], citations: [] },
|
||||
},
|
||||
@@ -239,12 +239,12 @@ describe("ResearchView", () => {
|
||||
const attachRunToTask = vi.fn().mockResolvedValue({});
|
||||
mockUseResearch.mockReturnValue({
|
||||
...baseHookValue,
|
||||
runs: [{ id: "RR-1", title: "t", query: "q", status: "pending" }],
|
||||
runs: [{ id: "RR-1", title: "t", query: "q", status: "queued" }],
|
||||
selectedRun: {
|
||||
id: "RR-1",
|
||||
title: "t",
|
||||
query: "q",
|
||||
status: "pending",
|
||||
status: "queued",
|
||||
events: [],
|
||||
results: { summary: "Summary", findings: [{ id: "finding-1", heading: "Finding", content: "Impact." }], citations: [] },
|
||||
},
|
||||
@@ -298,7 +298,7 @@ describe("ResearchView", () => {
|
||||
setSearchQuery,
|
||||
setSelectedRunId,
|
||||
runs: [
|
||||
{ id: "RR-1", title: "Alpha", query: "alpha", status: "pending" },
|
||||
{ id: "RR-1", title: "Alpha", query: "alpha", status: "queued" },
|
||||
{ id: "RR-2", title: "Beta", query: "beta", status: "completed" },
|
||||
],
|
||||
});
|
||||
@@ -460,6 +460,66 @@ describe("ResearchView", () => {
|
||||
expect(await screen.findByTestId("research-state-empty")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("wires create-task modal payload with trimmed fields and attachment toggle", async () => {
|
||||
const createTaskFromRun = vi.fn().mockResolvedValue({});
|
||||
mockUseResearch.mockReturnValue({
|
||||
...baseHookValue,
|
||||
createTaskFromRun,
|
||||
runs: [{ id: "RR-1", title: "t", query: "q", status: "completed" }],
|
||||
selectedRun: {
|
||||
id: "RR-1",
|
||||
title: "t",
|
||||
query: "q",
|
||||
status: "completed",
|
||||
events: [],
|
||||
results: { summary: "Summary", findings: [{ id: "finding-1", heading: "Finding", content: "Impact." }], citations: [] },
|
||||
},
|
||||
selectedRunId: "RR-1",
|
||||
});
|
||||
mockFetchAuthStatus.mockResolvedValue({ providers: [{ id: "openrouter", type: "api_key", authenticated: true }] });
|
||||
|
||||
render(<ResearchView projectId="p1" />);
|
||||
fireEvent.click((await screen.findAllByText("Create Task"))[0]);
|
||||
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
fireEvent.change(within(dialog).getByLabelText("Title"), { target: { value: " Follow up task " } });
|
||||
fireEvent.change(within(dialog).getByLabelText("Description"), { target: { value: " Take action now. " } });
|
||||
fireEvent.click(within(dialog).getByLabelText("Attach markdown export artifact"));
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createTaskFromRun).toHaveBeenCalledWith("RR-1", "Follow up task", "finding-1", "Take action now.", "normal", true);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps enrich action disabled until a task id is provided", async () => {
|
||||
mockUseResearch.mockReturnValue({
|
||||
...baseHookValue,
|
||||
runs: [{ id: "RR-1", title: "t", query: "q", status: "completed" }],
|
||||
selectedRun: {
|
||||
id: "RR-1",
|
||||
title: "t",
|
||||
query: "q",
|
||||
status: "completed",
|
||||
events: [],
|
||||
results: { summary: "Summary", findings: [{ id: "finding-1", heading: "Finding", content: "Impact." }], citations: [] },
|
||||
},
|
||||
selectedRunId: "RR-1",
|
||||
});
|
||||
mockFetchAuthStatus.mockResolvedValue({ providers: [{ id: "openrouter", type: "api_key", authenticated: true }] });
|
||||
|
||||
render(<ResearchView projectId="p1" />);
|
||||
fireEvent.click((await screen.findAllByText("Enrich Task"))[0]);
|
||||
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
const enrichButton = within(dialog).getByRole("button", { name: "Enrich Task" });
|
||||
expect(enrichButton).toBeDisabled();
|
||||
|
||||
const targetInput = within(dialog).getByRole("combobox", { name: "Target task" });
|
||||
fireEvent.change(targetInput, { target: { value: "FN-1" } });
|
||||
await waitFor(() => expect(enrichButton).not.toBeDisabled());
|
||||
});
|
||||
|
||||
it("includes mobile layout media rule", async () => {
|
||||
const css = await import("../ResearchView.css?inline");
|
||||
expect(css.default).toContain("@media (max-width: 768px)");
|
||||
|
||||
@@ -35,6 +35,7 @@ vi.mock("../../sse-bus", () => ({
|
||||
describe("useResearch", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
mockListResearchRuns.mockResolvedValue({ runs: [], availability: { available: true } });
|
||||
mockGetResearchRun.mockResolvedValue({ run: { id: "RR-2", title: "t" }, availability: { available: true } });
|
||||
});
|
||||
@@ -223,4 +224,32 @@ describe("useResearch", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("refreshes list and selected run on reconnect", async () => {
|
||||
let handlers: { onReconnect?: () => void; events?: Record<string, () => void> } = {};
|
||||
mockSubscribeSse.mockImplementationOnce((_url, opts) => {
|
||||
handlers = opts;
|
||||
return vi.fn();
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useResearch({ projectId: "p1" }));
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedRunId("RR-2");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetResearchRun).toHaveBeenCalledWith("RR-2", "p1");
|
||||
});
|
||||
|
||||
const listCallsBefore = mockListResearchRuns.mock.calls.length;
|
||||
|
||||
act(() => {
|
||||
handlers.onReconnect?.();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockListResearchRuns.mock.calls.length).toBeGreaterThan(listCallsBefore);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { lazy, Suspense, type LazyExoticComponent, type ReactNode } from "react";
|
||||
import { lazy, Suspense, type LazyExoticComponent, type ReactElement, type ReactNode } from "react";
|
||||
import { ErrorBoundary } from "../components/ErrorBoundary";
|
||||
import "./pluginViewRegistry.css";
|
||||
|
||||
export type PluginTaskView = `plugin:${string}:${string}`;
|
||||
|
||||
type PluginViewComponent = LazyExoticComponent<() => JSX.Element>;
|
||||
type PluginViewComponent = LazyExoticComponent<() => ReactElement>;
|
||||
|
||||
const registry = new Map<string, PluginViewComponent>();
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ function createMockStore(options?: {
|
||||
}
|
||||
return { filename: "RR-1-finding-1.md" };
|
||||
}),
|
||||
appendAgentLog: vi.fn(async () => undefined),
|
||||
log: vi.fn(async () => undefined),
|
||||
};
|
||||
}
|
||||
@@ -197,10 +198,26 @@ describe("research-routes", () => {
|
||||
expect.objectContaining({
|
||||
source: expect.objectContaining({
|
||||
sourceType: "research",
|
||||
sourceMetadata: expect.objectContaining({ runId: "RR-1", findingId: "finding-1" }),
|
||||
sourceRunId: "RR-1",
|
||||
sourceMetadata: expect.objectContaining({ runId: "RR-1", findingId: "finding-1", documentKey: "research-RR-1" }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(store.upsertTaskDocument).toHaveBeenCalledWith(
|
||||
"FN-1",
|
||||
expect.objectContaining({
|
||||
key: "research-RR-1",
|
||||
author: "research",
|
||||
metadata: expect.objectContaining({ runId: "RR-1", findingId: "finding-1" }),
|
||||
}),
|
||||
);
|
||||
expect(store.appendAgentLog).toHaveBeenCalledWith(
|
||||
"FN-1",
|
||||
expect.stringContaining("Task created from research finding finding-1 in run RR-1"),
|
||||
"text",
|
||||
"research-task-integration",
|
||||
"executor",
|
||||
);
|
||||
});
|
||||
|
||||
it("enriches existing task from finding and returns revision", async () => {
|
||||
@@ -221,6 +238,21 @@ describe("research-routes", () => {
|
||||
expect(response.body.taskId).toBe("FN-42");
|
||||
expect(response.body.documentKey).toBe("research-RR-1");
|
||||
expect(response.body.revision).toBe(1);
|
||||
expect(store.upsertTaskDocument).toHaveBeenCalledWith(
|
||||
"FN-42",
|
||||
expect.objectContaining({
|
||||
key: "research-RR-1",
|
||||
author: "research",
|
||||
metadata: expect.objectContaining({ runId: "RR-1", findingId: "finding-1" }),
|
||||
}),
|
||||
);
|
||||
expect(store.appendAgentLog).toHaveBeenCalledWith(
|
||||
"FN-42",
|
||||
expect.stringContaining("Task enriched from research finding finding-1 in run RR-1"),
|
||||
"text",
|
||||
"research-task-integration",
|
||||
"executor",
|
||||
);
|
||||
});
|
||||
|
||||
it("skips duplicate attachment when original name already exists", async () => {
|
||||
@@ -401,6 +433,29 @@ describe("research-routes", () => {
|
||||
expect(response.body.error).toContain("attachExport must be a boolean");
|
||||
});
|
||||
|
||||
it("returns 400 when create payload title/description are empty strings", async () => {
|
||||
const store = createMockStore();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(createResearchRouter(store as any));
|
||||
|
||||
const response = await performRequest(
|
||||
app,
|
||||
"POST",
|
||||
"/runs/RR-1/findings/finding-1/task",
|
||||
JSON.stringify({ title: " ", description: " " }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(store.createTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "Research: Finding One",
|
||||
description: expect.stringContaining("Important actionable result."),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 400 when attachment exceeds size limit", async () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
Reference in New Issue
Block a user