Files
fusion/packages/dashboard/app/components/__tests__/RoutingTab.test.tsx
Fusion b42eac3891 feat(FN-2731): merge fusion/fn-2731
- feat(FN-2731): add changeset for multi-node routing feature
- feat(FN-2730): merge fusion/fn-2730
- feat(FN-2729): merge fusion/fn-2729
- chore(release): v0.8.4
- chore(dashboard): tidy stray workspace artifacts
- refactor(core,merger): consolidate AI commit-body summarization into ai-summarize.ts
- feat(merger,dashboard): use title-summarization model for commit body AI + reword settings
- fix(engine): guarantee non-empty merge commit body via AI fallback cascade
- fix(FN-XXX): sync workspace version on release
- feat(FN-2729): unify node status indicator with NodeHealthDot
- fix(FN-0000): harden sqlite startup validation
- fix(FN-XXX): align tui startup update check
- fix(FN-XXX): harden windows path handling
- fix(engine): tighten Layer 3 — pass safety constraint into AI prompt + harden Layer 2 restore
- feat(FN-2944): merge fusion/fn-2944
- fix(engine): auto-recover from squash-merge orphan rebase failures
- feat(FN-2948): merge fusion/fn-2948
- feat(FN-2939): merge fusion/fn-2939
- feat(FN-2946): merge fusion/fn-2946
- feat(FN-2941): merge fusion/fn-2941
- feat(FN-2938): merge fusion/fn-2938
- feat(FN-2929): merge fusion/fn-2929
- feat(FN-2934): merge fusion/fn-2934
- feat(FN-2930): merge fusion/fn-2930
- feat(FN-2928): merge fusion/fn-2928
- feat(FN-2927): merge fusion/fn-2927
- feat(FN-2916): streamline Cloudflare quick tunnel settings UX
- feat(FN-2913): merge fusion/fn-2913
- feat(FN-2911): merge fusion/fn-2911
- feat(FN-2901): merge fusion/fn-2901
- feat(FN-2729): merge fusion/fn-2729

Fusion-Task-Id: FN-2731
2026-04-29 09:16:23 -07:00

216 lines
6.9 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { Settings, Task } from "@fusion/core";
import { RoutingTab } from "../RoutingTab";
import * as api from "../../api";
vi.mock("lucide-react", () => ({}));
vi.mock("../../api", async () => {
const actual = await vi.importActual<typeof api>("../../api");
return {
...actual,
fetchNodes: vi.fn(),
updateTask: vi.fn(),
};
});
const mockFetchNodes = api.fetchNodes as ReturnType<typeof vi.fn>;
const mockUpdateTask = api.updateTask as ReturnType<typeof vi.fn>;
function makeTask(overrides: Partial<Task> = {}): Task {
return {
id: "FN-001",
description: "Routing test task",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
type RoutingSettings = Settings & {
defaultNodeId?: string;
unavailableNodePolicy?: "block" | "fallback-local";
};
function makeSettings(overrides: Partial<RoutingSettings> = {}): RoutingSettings {
return {
maxConcurrent: 2,
maxWorktrees: 2,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
...overrides,
};
}
describe("RoutingTab", () => {
const addToast = vi.fn();
const onTaskUpdated = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
mockFetchNodes.mockResolvedValue([
{ id: "node-a", name: "Alpha", type: "local", status: "online" },
{ id: "node-b", name: "Beta", type: "remote", status: "offline" },
]);
mockUpdateTask.mockImplementation(async (_id: string, updates: { nodeId?: string | null }) => {
return makeTask({ nodeId: updates.nodeId ?? undefined });
});
});
it("renders routing summary with per-task override", async () => {
render(
<RoutingTab
task={makeTask({ nodeId: "node-a" })}
settings={makeSettings({ defaultNodeId: "node-b" })}
addToast={addToast}
/>,
);
expect(await screen.findByText("Per-task override")).toBeInTheDocument();
expect(screen.getByText(/Effective node/i)).toBeInTheDocument();
expect(screen.getAllByText("Alpha (local)")[0]).toBeInTheDocument();
expect(document.querySelector(".status-dot--online")).toBeInTheDocument();
});
it("renders selector options with status text", async () => {
render(<RoutingTab task={makeTask()} settings={makeSettings()} addToast={addToast} />);
expect(await screen.findByRole("option", { name: "Alpha (local) — online" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Beta (remote) — offline" })).toBeInTheDocument();
});
it("renders routing summary with project default", async () => {
render(
<RoutingTab
task={makeTask()}
settings={makeSettings({ defaultNodeId: "node-b" })}
addToast={addToast}
/>,
);
expect(await screen.findByText("Project default")).toBeInTheDocument();
expect(screen.getByText(/Effective node/i)).toBeInTheDocument();
expect(screen.getByText("Unhealthy")).toBeInTheDocument();
});
it("renders NodeHealthDot in routing summary for effective node", async () => {
render(
<RoutingTab
task={makeTask({ nodeId: "node-a" })}
settings={makeSettings()}
addToast={addToast}
/>,
);
await screen.findByText("Alpha (local)");
expect(document.querySelector(".status-dot--online")).toBeInTheDocument();
});
it("renders NodeHealthDot and Unhealthy badge for offline effective node", async () => {
render(
<RoutingTab
task={makeTask()}
settings={makeSettings({ defaultNodeId: "node-b" })}
addToast={addToast}
/>,
);
await screen.findByText("Unhealthy");
expect(document.querySelector(".status-dot--offline")).toBeInTheDocument();
});
it("renders no-routing summary when no override or project default exists", async () => {
render(<RoutingTab task={makeTask()} settings={makeSettings()} addToast={addToast} />);
expect(await screen.findByText("Local (no routing configured)")).toBeInTheDocument();
expect(screen.getByText("No routing")).toBeInTheDocument();
});
it.each([
["block", "Block execution"],
["fallback-local", "Fall back to local"],
] as const)("displays unavailable-node policy: %s", async (policy, label) => {
render(
<RoutingTab
task={makeTask()}
settings={makeSettings({ unavailableNodePolicy: policy })}
addToast={addToast}
/>,
);
await screen.findByText(label);
expect(screen.getByText(label)).toBeInTheDocument();
});
it("disables node selector for in-progress tasks", async () => {
render(<RoutingTab task={makeTask({ column: "in-progress" })} settings={makeSettings()} addToast={addToast} />);
const selector = await screen.findByLabelText("Select execution node");
expect(selector).toBeDisabled();
expect(screen.getByText("Node override cannot be changed while the task is active.")).toBeInTheDocument();
});
it("disables node selector for active task statuses", async () => {
render(<RoutingTab task={makeTask({ column: "todo", status: "executing" })} settings={makeSettings()} addToast={addToast} />);
const selector = await screen.findByLabelText("Select execution node");
expect(selector).toBeDisabled();
expect(screen.getByText("Node override cannot be changed while the task is active.")).toBeInTheDocument();
});
it("enables node selector for non-active tasks", async () => {
render(<RoutingTab task={makeTask({ column: "todo", status: "pending" })} settings={makeSettings()} addToast={addToast} />);
const selector = await screen.findByLabelText("Select execution node");
await waitFor(() => {
expect(selector).toBeEnabled();
});
});
it("calls updateTask when node selected", async () => {
const user = userEvent.setup();
render(
<RoutingTab
task={makeTask({ column: "todo" })}
settings={makeSettings()}
addToast={addToast}
onTaskUpdated={onTaskUpdated}
/>,
);
const selector = await screen.findByLabelText("Select execution node");
await user.selectOptions(selector, "node-a");
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", { nodeId: "node-a" });
});
});
it("shows clear override button and clears node override", async () => {
const user = userEvent.setup();
render(
<RoutingTab
task={makeTask({ nodeId: "node-a" })}
settings={makeSettings()}
addToast={addToast}
onTaskUpdated={onTaskUpdated}
/>,
);
const clearButton = await screen.findByRole("button", { name: "Clear override" });
await user.click(clearButton);
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", { nodeId: null });
});
});
});