fix(FN-2444): polish node dashboard UX and discovery core handling
- Update node management UI components to use shared form/button classes, semantic color tokens, and accessible interactions - Refine mesh topology rendering and related dashboard tests for improved readability and behavior coverage - Adjust discovery routes to reuse injected centralCore instances without unnecessary init/close cycles - Add/extend tests for shared CentralCore discovery flows and model settings scope save behavior
This commit is contained in:
@@ -64,6 +64,20 @@ describe("MeshTopology", () => {
|
||||
expect(links).toHaveLength(1); // One line from local to remote
|
||||
});
|
||||
|
||||
it("does not render fabricated peer links between remote nodes", () => {
|
||||
const nodes = [
|
||||
makeNode({ id: "local", name: "Local", type: "local" }),
|
||||
makeNode({ id: "remote-1", name: "Remote 1", type: "remote" }),
|
||||
makeNode({ id: "remote-2", name: "Remote 2", type: "remote" }),
|
||||
makeNode({ id: "remote-3", name: "Remote 3", type: "remote" }),
|
||||
];
|
||||
|
||||
render(<MeshTopology nodes={nodes} />);
|
||||
|
||||
expect(document.querySelectorAll(".mesh-topology__peer-line")).toHaveLength(0);
|
||||
expect(screen.getByText("Peer-to-peer discovery data unavailable.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders legend with status colors", () => {
|
||||
render(<MeshTopology nodes={[makeNode()]} />);
|
||||
|
||||
@@ -123,6 +137,19 @@ describe("MeshTopology", () => {
|
||||
expect(circles[0]).toHaveAttribute("fill", expect.stringContaining("var(--color-error"));
|
||||
});
|
||||
|
||||
it("uses consistent node type badges instead of emoji glyphs", () => {
|
||||
const nodes = [
|
||||
makeNode({ id: "local", name: "Local", type: "local" }),
|
||||
makeNode({ id: "remote", name: "Remote", type: "remote" }),
|
||||
];
|
||||
render(<MeshTopology nodes={nodes} />);
|
||||
|
||||
const typeBadges = document.querySelectorAll(".mesh-topology__node-type-badge");
|
||||
expect(typeBadges).toHaveLength(2);
|
||||
expect(screen.queryByText("🏠")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("🌐")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders correct status color for connecting nodes", () => {
|
||||
const nodes = [makeNode({ id: "connecting", name: "Connecting Node", status: "connecting" })];
|
||||
render(<MeshTopology nodes={nodes} />);
|
||||
|
||||
@@ -1063,11 +1063,11 @@ describe("SettingsModal", () => {
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
// Click on "Models" (global models section)
|
||||
fireEvent.click(screen.getByText("Models"));
|
||||
fireEvent.click((await screen.findAllByText("Models"))[0]);
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
// Select a model
|
||||
const trigger = screen.getByLabelText("Default Model");
|
||||
const trigger = await screen.findByLabelText("Default Model");
|
||||
await user.click(trigger);
|
||||
await user.click(screen.getByText("Claude Sonnet 4.5"));
|
||||
|
||||
@@ -1347,11 +1347,11 @@ describe("SettingsModal", () => {
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
// Models section has default model dropdown
|
||||
fireEvent.click(screen.getByText("Models"));
|
||||
fireEvent.click((await screen.findAllByText("Models"))[0]);
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
// Open dropdown and select "Use default"
|
||||
const trigger = screen.getByLabelText("Default Model");
|
||||
const trigger = await screen.findByLabelText("Default Model");
|
||||
await user.click(trigger);
|
||||
|
||||
// Find and click the "Use default" option in the dropdown
|
||||
@@ -2341,7 +2341,7 @@ describe("SettingsModal", () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Notifications"));
|
||||
fireEvent.click((await screen.findAllByText("Notifications"))[0]);
|
||||
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications");
|
||||
expect(checkbox).toBeTruthy();
|
||||
expect(checkbox.getAttribute("type")).toBe("checkbox");
|
||||
@@ -2606,7 +2606,7 @@ describe("SettingsModal", () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Notifications"));
|
||||
fireEvent.click((await screen.findAllByText("Notifications"))[0]);
|
||||
expect((screen.getByLabelText("Task completed (in-review)") as HTMLInputElement).checked).toBe(true);
|
||||
expect((screen.getByLabelText("Task merged") as HTMLInputElement).checked).toBe(true);
|
||||
expect((screen.getByLabelText("Task failed") as HTMLInputElement).checked).toBe(true);
|
||||
@@ -2656,7 +2656,7 @@ describe("SettingsModal", () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Notifications"));
|
||||
fireEvent.click((await screen.findAllByText("Notifications"))[0]);
|
||||
|
||||
// Uncheck all six
|
||||
fireEvent.click(screen.getByLabelText("Task completed (in-review)"));
|
||||
@@ -2726,7 +2726,7 @@ describe("SettingsModal", () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Node Sync"));
|
||||
fireEvent.click((await screen.findAllByText("Node Sync"))[0]);
|
||||
expect(screen.getByLabelText("Enable automatic settings sync")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Sync model auth credentials")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Sync interval")).toBeTruthy();
|
||||
@@ -3582,7 +3582,7 @@ describe("Prompts section", () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getAllByText("Prompts")[0]);
|
||||
fireEvent.click((await screen.findAllByText("Prompts"))[0]);
|
||||
|
||||
// Templates tab should be active by default
|
||||
expect(screen.getByTestId("tab-templates")).toHaveClass(/active/);
|
||||
@@ -3612,7 +3612,7 @@ describe("Prompts section", () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getAllByText("Prompts")[0]);
|
||||
fireEvent.click((await screen.findAllByText("Prompts"))[0]);
|
||||
|
||||
// Click Overrides tab
|
||||
fireEvent.click(screen.getByTestId("tab-overrides"));
|
||||
@@ -3626,7 +3626,7 @@ describe("Prompts section", () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getAllByText("Prompts")[0]);
|
||||
fireEvent.click((await screen.findAllByText("Prompts"))[0]);
|
||||
|
||||
// Click Overrides tab
|
||||
fireEvent.click(screen.getByTestId("tab-overrides"));
|
||||
@@ -3897,8 +3897,8 @@ describe("Prompts section", () => {
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
// Verify default and fallback model dropdowns
|
||||
expect(screen.getByLabelText("Default Model")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Fallback Model")).toBeTruthy();
|
||||
expect(await screen.findByLabelText("Default Model")).toBeTruthy();
|
||||
expect(await screen.findByLabelText("Fallback Model")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders global baseline lane dropdowns in Models section", async () => {
|
||||
@@ -3922,7 +3922,7 @@ describe("Prompts section", () => {
|
||||
fireEvent.click(screen.getAllByText("Models")[0]);
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
const executionTrigger = screen.getByLabelText("Execution Model");
|
||||
const executionTrigger = await screen.findByLabelText("Execution Model");
|
||||
await user.click(executionTrigger);
|
||||
await user.click(screen.getByText("GPT-4o"));
|
||||
|
||||
@@ -3948,7 +3948,7 @@ describe("Prompts section", () => {
|
||||
fireEvent.click(screen.getAllByText("Models")[0]);
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
const executionTrigger = screen.getByLabelText("Execution Model");
|
||||
const executionTrigger = await screen.findByLabelText("Execution Model");
|
||||
await user.click(executionTrigger);
|
||||
const useDefaultOption = await screen.findByRole("option", { name: /use default/i });
|
||||
await user.click(useDefaultOption);
|
||||
@@ -3965,7 +3965,7 @@ describe("Prompts section", () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getAllByText("Models")[0]);
|
||||
fireEvent.click((await screen.findAllByText("Models"))[0]);
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
expect(screen.getByLabelText("Execution Model").textContent).toContain("Use default");
|
||||
@@ -3978,7 +3978,7 @@ describe("Prompts section", () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getAllByText("Models")[0]);
|
||||
fireEvent.click((await screen.findAllByText("Models"))[0]);
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
// Thinking Effort is a native select
|
||||
@@ -4136,6 +4136,54 @@ describe("Prompts section", () => {
|
||||
expect(globalPayload.planningModelId).toBeUndefined();
|
||||
}
|
||||
}, FN1712_SCOPE_TEST_TIMEOUT_MS);
|
||||
|
||||
it("mixed global and project changes call both endpoints with correct subsets", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
// Change global model
|
||||
fireEvent.click(screen.getAllByText("Models")[0]);
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
let user = userEvent.setup();
|
||||
let dropdownBtn = screen.getByRole("button", { name: /default model/i });
|
||||
await user.click(dropdownBtn);
|
||||
let option = await screen.findByRole("option", { name: /gpt-4o/i });
|
||||
await user.click(option);
|
||||
|
||||
// Change project model
|
||||
fireEvent.click(screen.getAllByText("Project Models")[0]);
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
dropdownBtn = screen.getByRole("button", { name: /planning model/i });
|
||||
await user.click(dropdownBtn);
|
||||
option = await screen.findByRole("option", { name: /claude/i });
|
||||
await user.click(option);
|
||||
|
||||
// Save
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
await waitFor(() => {
|
||||
expect(updateSettings).toHaveBeenCalled();
|
||||
expect(updateGlobalSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const globalCalls = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const projectCalls = (updateSettings as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const globalPayload = globalCalls[globalCalls.length - 1][0];
|
||||
const projectPayload = projectCalls[projectCalls.length - 1][0];
|
||||
|
||||
// Verify global payload contains only global keys
|
||||
expect(globalPayload).toHaveProperty("defaultProvider");
|
||||
expect(globalPayload).toHaveProperty("defaultModelId");
|
||||
expect(globalPayload.planningProvider).toBeUndefined();
|
||||
expect(globalPayload.planningModelId).toBeUndefined();
|
||||
|
||||
// Verify project payload contains only project keys
|
||||
expect(projectPayload).toHaveProperty("planningProvider");
|
||||
expect(projectPayload).toHaveProperty("planningModelId");
|
||||
expect(projectPayload.defaultProvider).toBeUndefined();
|
||||
expect(projectPayload.defaultModelId).toBeUndefined();
|
||||
}, FN1712_SCOPE_TEST_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
describe("Reset/clear null-as-delete semantics (FN-1712)", () => {
|
||||
|
||||
@@ -3189,7 +3189,9 @@ describe("TerminalModal — FN-872 real-device keyboard overlap refinement", ()
|
||||
});
|
||||
|
||||
// resize should have been called with xterm dimensions
|
||||
expect(mockResize).toHaveBeenCalledWith(80, 24);
|
||||
await waitFor(() => {
|
||||
expect(mockResize).toHaveBeenCalledWith(80, 24);
|
||||
});
|
||||
});
|
||||
|
||||
it("clears --vv-height when keyboard closes", async () => {
|
||||
|
||||
@@ -44,10 +44,10 @@ describe("nodes-view mobile CSS", () => {
|
||||
expect(block).toContain("flex-wrap: wrap");
|
||||
});
|
||||
|
||||
it("defines .nodes-view-title h2 with 16px font on mobile", () => {
|
||||
it("defines .nodes-view-title h2 with tokenized font size on mobile", () => {
|
||||
expect(mobileMediaBlock).toContain(".nodes-view-title h2");
|
||||
const block = extractRuleBlock(mobileMediaBlock, ".nodes-view-title h2");
|
||||
expect(block).toContain("font-size: 16px");
|
||||
expect(block).toContain("font-size: calc(var(--space-md) + var(--space-xs));");
|
||||
});
|
||||
|
||||
it("defines .nodes-view-title h2 svg with flex-shrink: 0 on mobile", () => {
|
||||
@@ -56,17 +56,17 @@ describe("nodes-view mobile CSS", () => {
|
||||
expect(block).toContain("flex-shrink: 0");
|
||||
});
|
||||
|
||||
it("defines .nodes-view-count with smaller font on mobile", () => {
|
||||
it("defines .nodes-view-count with tokenized smaller font on mobile", () => {
|
||||
expect(mobileMediaBlock).toContain(".nodes-view-count");
|
||||
const block = extractRuleBlock(mobileMediaBlock, ".nodes-view-count");
|
||||
expect(block).toContain("font-size: 12px");
|
||||
expect(block).toContain("font-size: calc(var(--space-sm) + var(--space-xs));");
|
||||
});
|
||||
|
||||
it("defines .nodes-view-close with 36px touch target on mobile", () => {
|
||||
it("defines .nodes-view-close with tokenized touch target on mobile", () => {
|
||||
expect(mobileMediaBlock).toContain(".nodes-view-close");
|
||||
const block = extractRuleBlock(mobileMediaBlock, ".nodes-view-close");
|
||||
expect(block).toContain("min-height: 36px");
|
||||
expect(block).toContain("min-width: 36px");
|
||||
expect(block).toContain("min-height: calc(var(--space-xl) + var(--space-md));");
|
||||
expect(block).toContain("min-width: calc(var(--space-xl) + var(--space-md));");
|
||||
});
|
||||
|
||||
it("defines .nodes-view-actions with full width on mobile", () => {
|
||||
@@ -77,10 +77,10 @@ describe("nodes-view mobile CSS", () => {
|
||||
expect(block).toContain("justify-content: flex-end");
|
||||
});
|
||||
|
||||
it("defines .nodes-view-actions .btn with min-height on mobile", () => {
|
||||
it("defines .nodes-view-actions .btn with tokenized min-height on mobile", () => {
|
||||
expect(mobileMediaBlock).toContain(".nodes-view-actions .btn");
|
||||
const block = extractRuleBlock(mobileMediaBlock, ".nodes-view-actions .btn");
|
||||
expect(block).toContain("min-height: 36px");
|
||||
expect(block).toContain("min-height: calc(var(--space-xl) + var(--space-md));");
|
||||
});
|
||||
|
||||
it("defines .nodes-view-stats with 2-column grid on mobile", () => {
|
||||
@@ -96,16 +96,16 @@ describe("nodes-view mobile CSS", () => {
|
||||
expect(block).toContain("padding: var(--space-xs) var(--space-sm)");
|
||||
});
|
||||
|
||||
it("defines .nodes-view-stat span with smaller font on mobile", () => {
|
||||
it("defines .nodes-view-stat span with tokenized smaller font on mobile", () => {
|
||||
expect(mobileMediaBlock).toContain(".nodes-view-stat span");
|
||||
const block = extractRuleBlock(mobileMediaBlock, ".nodes-view-stat span");
|
||||
expect(block).toContain("font-size: 11px");
|
||||
expect(block).toContain("font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);");
|
||||
});
|
||||
|
||||
it("defines .nodes-view-stat strong with smaller font on mobile", () => {
|
||||
it("defines .nodes-view-stat strong with tokenized smaller font on mobile", () => {
|
||||
expect(mobileMediaBlock).toContain(".nodes-view-stat strong");
|
||||
const block = extractRuleBlock(mobileMediaBlock, ".nodes-view-stat strong");
|
||||
expect(block).toContain("font-size: 14px");
|
||||
expect(block).toContain("font-size: calc(var(--space-md) + var(--space-xs) / 2);");
|
||||
});
|
||||
|
||||
it("defines .nodes-view-empty with compact padding on mobile", () => {
|
||||
@@ -128,9 +128,9 @@ describe("nodes-view mobile CSS", () => {
|
||||
expect(block).toContain("padding: var(--space-sm) 0");
|
||||
});
|
||||
|
||||
it("defines .nodes-view-section-title with smaller font on mobile", () => {
|
||||
it("defines .nodes-view-section-title with tokenized smaller font on mobile", () => {
|
||||
expect(mobileMediaBlock).toContain(".nodes-view-section-title");
|
||||
const block = extractRuleBlock(mobileMediaBlock, ".nodes-view-section-title");
|
||||
expect(block).toContain("font-size: 13px");
|
||||
expect(block).toContain("font-size: calc(var(--space-md) + var(--space-xs) / 4);");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -770,7 +770,9 @@ describe("onboarding flow integration", () => {
|
||||
|
||||
expect(mockMarkOnboardingCompleted).toHaveBeenCalled();
|
||||
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({ modelOnboardingComplete: true });
|
||||
expect(screen.getByText("Your first task is ready!")).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Your first task is ready!")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("task creation flow: View Task button navigates and completes onboarding", async () => {
|
||||
|
||||
Reference in New Issue
Block a user