feat(FN-1850): aggregate projects across connected nodes
- Add /api/projects/across-nodes to merge local projects with online remote node project lists - Expose fetchProjectsAcrossNodes and ProjectInfoWithSource, and switch useProjects to consume cross-node data - Update ProjectOverview and ProjectCard with node badges, node count stats, and a node filter dropdown plus responsive styles - Add server and dashboard test coverage for cross-node aggregation, filtering, and hook behavior changes - Include a changeset for @gsxdsm/fusion minor release and preserve stale-channel SSE reconnect guards during merge
This commit is contained in:
@@ -3280,6 +3280,17 @@ export function fetchProjects(): Promise<ProjectInfo[]> {
|
||||
return api<ProjectInfo[]>("/projects");
|
||||
}
|
||||
|
||||
/** Project info with source node metadata (added by server for remote projects) */
|
||||
export interface ProjectInfoWithSource extends ProjectInfo {
|
||||
/** Name of the source node (added by server for remote projects) */
|
||||
_sourceNodeName?: string;
|
||||
}
|
||||
|
||||
/** Fetch all registered projects from all nodes (local + remote) */
|
||||
export function fetchProjectsAcrossNodes(): Promise<ProjectInfoWithSource[]> {
|
||||
return api<ProjectInfoWithSource[]>("/projects/across-nodes");
|
||||
}
|
||||
|
||||
/** Fetch all registered nodes */
|
||||
export function fetchNodes(): Promise<NodeInfo[]> {
|
||||
return api<NodeInfo[]>("/nodes");
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface ProjectCardProps {
|
||||
onResume: (project: RegisteredProject) => void;
|
||||
onRemove: (project: RegisteredProject) => void;
|
||||
node?: NodeInfo;
|
||||
/** Fallback node name when the node object is not available (e.g., for remote projects) */
|
||||
nodeNameFallback?: string;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
@@ -71,7 +73,11 @@ function areProjectCardPropsEqual(previous: ProjectCardProps, next: ProjectCardP
|
||||
|
||||
const prevNode = previous.node;
|
||||
const nextNode = next.node;
|
||||
if (!prevNode && !nextNode) return true;
|
||||
if (!prevNode && !nextNode) {
|
||||
// Compare fallback names
|
||||
if (previous.nodeNameFallback !== next.nodeNameFallback) return false;
|
||||
return true;
|
||||
}
|
||||
if (!prevNode || !nextNode) return false;
|
||||
|
||||
return (
|
||||
@@ -90,6 +96,7 @@ function ProjectCardInner({
|
||||
onResume,
|
||||
onRemove,
|
||||
node,
|
||||
nodeNameFallback,
|
||||
isLoading = false,
|
||||
}: ProjectCardProps) {
|
||||
const [removeArmed, setRemoveArmed] = useState(false);
|
||||
@@ -147,9 +154,9 @@ function ProjectCardInner({
|
||||
<h3 className="project-card-name" title={project.name}>
|
||||
{project.name}
|
||||
</h3>
|
||||
{node && (
|
||||
<span className="node-badge" title={`Assigned node: ${node.name}`}>
|
||||
on: {node.name}
|
||||
{(node || nodeNameFallback) && (
|
||||
<span className="node-badge" title={`Assigned node: ${node?.name ?? nodeNameFallback}`}>
|
||||
on: {node?.name ?? nodeNameFallback}
|
||||
</span>
|
||||
)}
|
||||
<span className="project-card-path" title={project.path}>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useState, useMemo, useCallback, useEffect } from "react";
|
||||
import { Plus, LayoutGrid, Filter, ArrowUpDown, Activity, CheckCircle, AlertCircle, Folder, Inbox } from "lucide-react";
|
||||
import type { ProjectInfo, ProjectHealth, NodeInfo } from "../api";
|
||||
import { Plus, LayoutGrid, Filter, ArrowUpDown, Activity, CheckCircle, AlertCircle, Folder, Inbox, Server } from "lucide-react";
|
||||
import type { ProjectInfo, ProjectHealth, NodeInfo, ProjectInfoWithSource } from "../api";
|
||||
import type { ProjectStatus } from "@fusion/core";
|
||||
import { ProjectCard } from "./ProjectCard";
|
||||
import { ProjectGridSkeleton } from "./ProjectGridSkeleton";
|
||||
import { useProjectHealth } from "../hooks/useProjectHealth";
|
||||
|
||||
export interface ProjectOverviewProps {
|
||||
projects: ProjectInfo[];
|
||||
projects: ProjectInfoWithSource[];
|
||||
loading?: boolean;
|
||||
onSelectProject: (project: ProjectInfo) => void;
|
||||
onAddProject: () => void;
|
||||
@@ -22,7 +22,7 @@ type FilterTab = "all" | "active" | "paused" | "errored";
|
||||
type SortOption = "name" | "activity" | "status";
|
||||
|
||||
interface ProjectWithHealth {
|
||||
project: ProjectInfo;
|
||||
project: ProjectInfoWithSource;
|
||||
health: ProjectHealth | null;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ export function ProjectOverview({
|
||||
nodes = [],
|
||||
}: ProjectOverviewProps) {
|
||||
const [activeFilter, setActiveFilter] = useState<FilterTab>("all");
|
||||
const [activeNodeFilter, setActiveNodeFilter] = useState<string | null>(null);
|
||||
const [sortBy, setSortBy] = useState<SortOption>("activity");
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
|
||||
|
||||
@@ -88,8 +89,13 @@ export function ProjectOverview({
|
||||
filtered = filtered.filter(({ project }) => project.status === activeFilter);
|
||||
}
|
||||
|
||||
// Filter by node if a node filter is active
|
||||
if (activeNodeFilter !== null) {
|
||||
filtered = filtered.filter(({ project }) => project.nodeId === activeNodeFilter);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [projectsWithHealth, activeFilter]);
|
||||
}, [projectsWithHealth, activeFilter, activeNodeFilter]);
|
||||
|
||||
// Sort projects
|
||||
const sortedProjects = useMemo(() => {
|
||||
@@ -130,6 +136,15 @@ export function ProjectOverview({
|
||||
const activeProjects = projects.filter((p) => p.status === "active").length;
|
||||
const erroredProjects = projects.filter((p) => p.status === "errored").length;
|
||||
|
||||
// Count unique nodes with projects (local + remote)
|
||||
const nodesWithProjects = new Set<string | undefined>();
|
||||
projects.forEach((p) => {
|
||||
if (p.nodeId) {
|
||||
nodesWithProjects.add(p.nodeId);
|
||||
}
|
||||
});
|
||||
const totalNodes = nodesWithProjects.size || (totalProjects > 0 ? 1 : 0);
|
||||
|
||||
let totalActiveTasks = 0;
|
||||
let totalCompletedTasks = 0;
|
||||
let totalInFlightAgents = 0;
|
||||
@@ -146,6 +161,7 @@ export function ProjectOverview({
|
||||
totalProjects,
|
||||
activeProjects,
|
||||
erroredProjects,
|
||||
totalNodes,
|
||||
totalActiveTasks,
|
||||
totalCompletedTasks,
|
||||
totalInFlightAgents,
|
||||
@@ -162,6 +178,31 @@ export function ProjectOverview({
|
||||
};
|
||||
}, [projects]);
|
||||
|
||||
// Node filter options with project counts
|
||||
const nodeFilterOptions = useMemo(() => {
|
||||
const nodeCounts = new Map<string | undefined, { name: string; count: number }>();
|
||||
|
||||
projects.forEach((p) => {
|
||||
const nodeId = p.nodeId;
|
||||
const existing = nodeCounts.get(nodeId);
|
||||
|
||||
if (existing) {
|
||||
existing.count++;
|
||||
} else {
|
||||
// Get the node name from the nodes list or use _sourceNodeName for remote projects
|
||||
const localNode = nodes.find((n) => n.id === nodeId);
|
||||
const nodeName = localNode?.name ?? p._sourceNodeName ?? "Local";
|
||||
nodeCounts.set(nodeId, { name: nodeName, count: 1 });
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(nodeCounts.entries()).map(([nodeId, { name, count }]) => ({
|
||||
nodeId: nodeId ?? null,
|
||||
name,
|
||||
count,
|
||||
}));
|
||||
}, [projects, nodes]);
|
||||
|
||||
// Handle sort change
|
||||
const handleSort = useCallback((option: SortOption) => {
|
||||
if (sortBy === option) {
|
||||
@@ -267,6 +308,17 @@ export function ProjectOverview({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{stats.totalNodes > 1 && (
|
||||
<div className="project-stat project-stat--nodes">
|
||||
<div className="project-stat__icon">
|
||||
<Server size={16} />
|
||||
</div>
|
||||
<div className="project-stat__content">
|
||||
<span className="project-stat__value">{stats.totalNodes}</span>
|
||||
<span className="project-stat__label">Nodes</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary project-overview__add-btn"
|
||||
@@ -310,6 +362,28 @@ export function ProjectOverview({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Node filter dropdown */}
|
||||
{nodeFilterOptions.length > 1 && (
|
||||
<div className="project-node-filter">
|
||||
<Server size={14} />
|
||||
<select
|
||||
value={activeNodeFilter ?? ""}
|
||||
onChange={(e) => {
|
||||
setActiveNodeFilter(e.target.value || null);
|
||||
}}
|
||||
className="project-node-filter-select"
|
||||
aria-label="Filter by node"
|
||||
>
|
||||
<option value="">All Nodes</option>
|
||||
{nodeFilterOptions.map(({ nodeId, name, count }) => (
|
||||
<option key={nodeId ?? "local"} value={nodeId ?? ""}>
|
||||
{name} ({count})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sort dropdown */}
|
||||
<div className="project-sort">
|
||||
<Filter size={14} />
|
||||
@@ -338,12 +412,15 @@ export function ProjectOverview({
|
||||
<div className="project-grid">
|
||||
{sortedProjects.map(({ project, health }) => {
|
||||
const projectNode = nodes.find((node) => node.id === project.nodeId);
|
||||
// Fallback: use server-provided _sourceNodeName for remote projects
|
||||
const nodeNameFallback = !projectNode ? project._sourceNodeName : undefined;
|
||||
return (
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
project={project}
|
||||
health={health}
|
||||
node={projectNode}
|
||||
nodeNameFallback={nodeNameFallback}
|
||||
onSelect={handleSelectProject}
|
||||
onPause={onPauseProject}
|
||||
onResume={onResumeProject}
|
||||
|
||||
@@ -143,7 +143,111 @@ const mockMilestoneValidationRollup = {
|
||||
blockedAssertions: 0,
|
||||
pendingAssertions: 0,
|
||||
unlinkedAssertions: 0,
|
||||
state: "not_started",
|
||||
state: "not_started" as const,
|
||||
};
|
||||
|
||||
/** Extended mock telemetry for parity tests — mirrors FN-1569 schema */
|
||||
const mockMilestoneValidationTelemetryWithRounds = {
|
||||
validationContract: {
|
||||
assertions: [
|
||||
{ id: "CA-001", title: "Auth works", assertion: "Users can log in", status: "pending" as const, orderIndex: 0 },
|
||||
{ id: "CA-002", title: "Session persists", assertion: "Token refresh works", status: "pending" as const, orderIndex: 1 },
|
||||
],
|
||||
featureFulfillment: {
|
||||
"F-001": { assertionIds: ["CA-001"], featureTitle: "User model", featureStatus: "in-progress" },
|
||||
},
|
||||
},
|
||||
validationTelemetry: {
|
||||
validationRounds: [
|
||||
{
|
||||
roundId: "VR-001",
|
||||
featureId: "F-001",
|
||||
featureTitle: "User model",
|
||||
validatorStatus: "failed" as const,
|
||||
implementationAttempt: 1,
|
||||
validatorAttempt: 2, // retry count (validatorAttempt = retry count)
|
||||
failedAssertionIds: ["CA-001"],
|
||||
generatedFixFeatureIds: [],
|
||||
startedAt: "2026-04-10T09:00:00.000Z",
|
||||
completedAt: "2026-04-10T09:05:00.000Z",
|
||||
},
|
||||
{
|
||||
roundId: "VR-002",
|
||||
featureId: "F-001",
|
||||
featureTitle: "User model",
|
||||
validatorStatus: "failed" as const,
|
||||
implementationAttempt: 2,
|
||||
validatorAttempt: 3, // higher retry count — iterating surface
|
||||
failedAssertionIds: ["CA-002"],
|
||||
generatedFixFeatureIds: [],
|
||||
startedAt: "2026-04-10T09:10:00.000Z",
|
||||
completedAt: "2026-04-10T09:15:00.000Z",
|
||||
},
|
||||
],
|
||||
lastValidatorStatus: "failed" as const,
|
||||
totalRuns: 2,
|
||||
},
|
||||
fixFeatures: [
|
||||
{
|
||||
id: "FF-001",
|
||||
title: "Fix: token refresh",
|
||||
sourceFeatureId: "F-001",
|
||||
runId: "VR-001",
|
||||
failedAssertionIds: ["CA-001"],
|
||||
status: "defined" as const,
|
||||
loopState: "idle" as const,
|
||||
},
|
||||
],
|
||||
rollup: {
|
||||
milestoneId: "MS-001",
|
||||
totalAssertions: 2,
|
||||
passedAssertions: 0,
|
||||
failedAssertions: 2,
|
||||
blockedAssertions: 0,
|
||||
pendingAssertions: 0,
|
||||
unlinkedAssertions: 0,
|
||||
state: "failed" as const,
|
||||
},
|
||||
};
|
||||
|
||||
/** Blocked milestone telemetry — mirrors FN-1569 blocked state */
|
||||
const mockBlockedMilestoneTelemetry = {
|
||||
validationContract: {
|
||||
assertions: [
|
||||
{ id: "CA-003", title: "API reachable", assertion: "External API responds", status: "blocked" as const, orderIndex: 0 },
|
||||
],
|
||||
featureFulfillment: {},
|
||||
},
|
||||
validationTelemetry: {
|
||||
validationRounds: [
|
||||
{
|
||||
roundId: "VR-BLK",
|
||||
featureId: "F-BLK",
|
||||
featureTitle: "API integration",
|
||||
validatorStatus: "blocked" as const,
|
||||
implementationAttempt: 1,
|
||||
validatorAttempt: 1,
|
||||
failedAssertionIds: ["CA-003"],
|
||||
generatedFixFeatureIds: [],
|
||||
blockedReason: "External API unavailable — connection refused after 3 retries",
|
||||
startedAt: "2026-04-10T10:00:00.000Z",
|
||||
completedAt: "2026-04-10T10:01:00.000Z",
|
||||
},
|
||||
],
|
||||
lastValidatorStatus: "blocked" as const,
|
||||
totalRuns: 1,
|
||||
},
|
||||
fixFeatures: [],
|
||||
rollup: {
|
||||
milestoneId: "MS-001",
|
||||
totalAssertions: 1,
|
||||
passedAssertions: 0,
|
||||
failedAssertions: 0,
|
||||
blockedAssertions: 1,
|
||||
pendingAssertions: 0,
|
||||
unlinkedAssertions: 0,
|
||||
state: "blocked" as const,
|
||||
},
|
||||
};
|
||||
|
||||
const mockMilestoneValidationTelemetry = {
|
||||
@@ -292,9 +396,10 @@ function parseMissionEventsResponse(url: string, events = mockMissionEvents) {
|
||||
};
|
||||
}
|
||||
|
||||
function getValidationApiMock(url: string): unknown | null {
|
||||
function getValidationApiMock(url: string, telemetryOverride?: unknown): unknown | null {
|
||||
const telemetry = telemetryOverride ?? mockMilestoneValidationTelemetry;
|
||||
if (url.includes("/validation-telemetry")) {
|
||||
return mockMilestoneValidationTelemetry;
|
||||
return telemetry;
|
||||
}
|
||||
|
||||
if (url.includes("/validation-runs")) {
|
||||
@@ -429,6 +534,73 @@ function createDetailFetchMock(events = mockMissionEvents) {
|
||||
});
|
||||
}
|
||||
|
||||
function createFetchMockWithTelemetry(telemetryOverride: unknown) {
|
||||
return vi.fn().mockImplementation((url: string) => {
|
||||
if (url.includes("/missions/health")) {
|
||||
return Promise.resolve(mockApiResponse(mockMissionHealthById));
|
||||
}
|
||||
|
||||
if (url.includes("/events")) {
|
||||
return Promise.resolve(mockApiResponse(parseMissionEventsResponse(url)));
|
||||
}
|
||||
|
||||
if (url.includes("/health")) {
|
||||
const missionId = extractMissionId(url) ?? "M-001";
|
||||
return Promise.resolve(mockApiResponse(getMockMissionHealth(missionId)));
|
||||
}
|
||||
|
||||
if (url.includes("/autopilot")) {
|
||||
return Promise.resolve(mockApiResponse(mockAutopilotStatus));
|
||||
}
|
||||
|
||||
const validationResponse = getValidationApiMock(url, telemetryOverride);
|
||||
if (validationResponse !== null) {
|
||||
return Promise.resolve(mockApiResponse(validationResponse));
|
||||
}
|
||||
|
||||
if (url.includes("/api/missions/") && !url.includes("/milestones") && !url.includes("/status")) {
|
||||
return Promise.resolve(mockApiResponse(mockMissionDetail));
|
||||
}
|
||||
|
||||
return Promise.resolve(mockApiResponse(mockMissions));
|
||||
});
|
||||
}
|
||||
|
||||
function createDetailFetchMockWithTelemetry(events: unknown[], telemetryOverride: unknown) {
|
||||
return vi.fn().mockImplementation((url: string) => {
|
||||
if (url.includes("/missions/health")) {
|
||||
return Promise.resolve(mockApiResponse(mockMissionHealthById));
|
||||
}
|
||||
|
||||
if (url.includes("/events")) {
|
||||
return Promise.resolve(mockApiResponse(parseMissionEventsResponse(url, events as typeof mockMissionEvents)));
|
||||
}
|
||||
|
||||
if (url.includes("/health")) {
|
||||
const missionId = extractMissionId(url) ?? "M-001";
|
||||
return Promise.resolve(mockApiResponse(getMockMissionHealth(missionId)));
|
||||
}
|
||||
|
||||
if (url.includes("/autopilot")) {
|
||||
return Promise.resolve(mockApiResponse(mockAutopilotStatus));
|
||||
}
|
||||
|
||||
const validationResponse = getValidationApiMock(url, telemetryOverride);
|
||||
if (validationResponse !== null) {
|
||||
return Promise.resolve(mockApiResponse(validationResponse));
|
||||
}
|
||||
|
||||
if (url.includes("/api/missions/") && !url.includes("/milestones") && !url.includes("/status")) {
|
||||
const missionId = extractMissionId(url);
|
||||
if (missionId === "M-001") {
|
||||
return Promise.resolve(mockApiResponse(mockMissionDetail));
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.resolve(mockApiResponse(mockMissions));
|
||||
});
|
||||
}
|
||||
|
||||
function createFetchMockWithHealth(
|
||||
missions: Array<Record<string, unknown>>,
|
||||
healthByMissionId: Record<string, unknown>,
|
||||
@@ -2415,4 +2587,192 @@ describe("MissionManager", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Step 2: Factory parity — contract/telemetry/fix-feature coverage ────────
|
||||
//
|
||||
// Validates FN-1569 schema parity from API telemetry payloads through UI rendering.
|
||||
// Extends test fixtures with validationContract, validationTelemetry, and fixFeatures
|
||||
// mirroring the exact schema fields used by MissionManager.tsx telemetry section.
|
||||
describe("Factory parity — contract/telemetry/fix-feature coverage", () => {
|
||||
it("renders validation telemetry section in detail view after API response", async () => {
|
||||
globalThis.fetch = createDetailFetchMockWithTelemetry(mockMissionEvents, mockMilestoneValidationTelemetryWithRounds);
|
||||
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Build Auth System")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByText("Build Auth System"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mission-back-btn")).toBeDefined();
|
||||
});
|
||||
|
||||
// After async telemetry loads, validation telemetry section should appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Validation Telemetry")).toBeDefined();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
// Total runs shown in header meta
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/2 rounds/)).toBeDefined();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
// Last validator status shown in header meta
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Last failed/)).toBeDefined();
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
|
||||
it("shows blocked reason surface when validation round is blocked", async () => {
|
||||
globalThis.fetch = createDetailFetchMockWithTelemetry(mockMissionEvents, mockBlockedMilestoneTelemetry);
|
||||
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Build Auth System")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByText("Build Auth System"));
|
||||
|
||||
// Wait for telemetry to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Validation Telemetry")).toBeDefined();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
// Last validator status shows blocked
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Last blocked/)).toBeDefined();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
// Blocked reason surface should appear (.mission-blocked-reason class)
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector(".mission-blocked-reason")).not.toBeNull();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
// Blocked reason text should be visible (use getAllByText since it may appear in both milestone-blocked-reason and round-blocked-reason)
|
||||
await waitFor(() => {
|
||||
const matches = screen.getAllByText(/External API unavailable/);
|
||||
expect(matches.length).toBeGreaterThan(0);
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
|
||||
it("does not show blocked-reason surface for failed (non-blocked) rounds", async () => {
|
||||
// Regression: failed rounds should NOT show blocked-reason surface
|
||||
globalThis.fetch = createDetailFetchMockWithTelemetry(mockMissionEvents, mockMilestoneValidationTelemetryWithRounds);
|
||||
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Build Auth System")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByText("Build Auth System"));
|
||||
|
||||
// Wait for telemetry to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Validation Telemetry")).toBeDefined();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
// Blocked reason text from the blocked telemetry should NOT appear
|
||||
// (the mockMissionDetail has a milestone without blocked telemetry)
|
||||
expect(screen.queryByText(/External API unavailable/)).toBeNull();
|
||||
});
|
||||
|
||||
it("displays fix-features with source linkage in telemetry section", async () => {
|
||||
globalThis.fetch = createDetailFetchMockWithTelemetry(mockMissionEvents, mockMilestoneValidationTelemetryWithRounds);
|
||||
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Build Auth System")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByText("Build Auth System"));
|
||||
|
||||
// Wait for telemetry to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Validation Telemetry/)).toBeDefined();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
// Fix features should appear with their source linkage
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Fix: token refresh")).toBeDefined();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
// Source feature ID should be visible (clickable link to source feature)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("F-001")).toBeDefined();
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
|
||||
it("blocked mission exposes resume affordance with aria-label", async () => {
|
||||
// Test that a mission with blocked status shows the Resume button
|
||||
const blockedMission = {
|
||||
...mockMissionDetail,
|
||||
status: "blocked" as const,
|
||||
};
|
||||
|
||||
globalThis.fetch = vi.fn().mockImplementation((url: string) => {
|
||||
if (url.includes("/missions/health")) {
|
||||
return Promise.resolve(mockApiResponse(mockMissionHealthById));
|
||||
}
|
||||
if (url.includes("/events")) {
|
||||
return Promise.resolve(mockApiResponse(parseMissionEventsResponse(url)));
|
||||
}
|
||||
if (url.includes("/health")) {
|
||||
const missionId = extractMissionId(url) ?? "M-001";
|
||||
return Promise.resolve(mockApiResponse(getMockMissionHealth(missionId)));
|
||||
}
|
||||
if (url.includes("/autopilot")) {
|
||||
return Promise.resolve(mockApiResponse(mockAutopilotStatus));
|
||||
}
|
||||
const validationResponse = getValidationApiMock(url);
|
||||
if (validationResponse !== null) {
|
||||
return Promise.resolve(mockApiResponse(validationResponse));
|
||||
}
|
||||
if (url.includes("/api/missions/") && !url.includes("/milestones") && !url.includes("/status")) {
|
||||
return Promise.resolve(mockApiResponse(blockedMission));
|
||||
}
|
||||
return Promise.resolve(mockApiResponse(mockMissions));
|
||||
});
|
||||
|
||||
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Build Auth System")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByText("Build Auth System"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mission-back-btn")).toBeDefined();
|
||||
});
|
||||
|
||||
// Resume button with aria-label="Resume mission" should appear for blocked mission
|
||||
await waitFor(() => {
|
||||
const resumeButton = screen.getByLabelText("Resume mission");
|
||||
expect(resumeButton).toBeDefined();
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
|
||||
it("activity tab metadata toggle still works after telemetry changes", async () => {
|
||||
// Regression: mission events metadata toggle (mission-event-metadata-*) must remain functional
|
||||
// Uses same pattern as existing passing test (lines ~912-923)
|
||||
globalThis.fetch = createDetailFetchMockWithTelemetry(mockMissionEvents, mockMilestoneValidationTelemetryWithRounds);
|
||||
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Build Auth System")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByText("Build Auth System"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mission-tab-activity")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("mission-tab-activity"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mission-activity-events")).toBeDefined();
|
||||
expect(screen.getByText("Mission started")).toBeDefined();
|
||||
});
|
||||
|
||||
// Toggle metadata for event E-002 which has metadata { queueDepth: 4 }
|
||||
fireEvent.click(screen.getByTestId("mission-event-metadata-E-002"));
|
||||
expect(screen.getByText(/"queueDepth": 4/)).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,11 @@ import { ProjectOverview } from "../ProjectOverview";
|
||||
import type { ProjectInfo, ProjectHealth } from "@fusion/core";
|
||||
import { useProjectHealth } from "../../hooks/useProjectHealth";
|
||||
|
||||
// Extended type with source node info for cross-node tests
|
||||
interface ProjectInfoWithSource extends ProjectInfo {
|
||||
_sourceNodeName?: string;
|
||||
}
|
||||
|
||||
// Default mock implementation
|
||||
function createDefaultHealthMap(projectIds: string[]): Record<string, ProjectHealth> {
|
||||
return projectIds.reduce((acc, id) => {
|
||||
@@ -45,14 +50,31 @@ vi.mock("lucide-react", async () => {
|
||||
AlertCircle: () => <span data-testid="alert-icon">⚠</span>,
|
||||
Folder: () => <span data-testid="folder-icon">📁</span>,
|
||||
Inbox: () => <span data-testid="inbox-icon">📥</span>,
|
||||
Server: () => <span data-testid="server-icon">🖥</span>,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock ProjectCard
|
||||
vi.mock("../ProjectCard", () => ({
|
||||
ProjectCard: ({ project, onSelect }: { project: ProjectInfo; onSelect: (p: ProjectInfo) => void }) => (
|
||||
<div data-testid={`project-card-${project.id}`} onClick={() => onSelect(project)}>
|
||||
ProjectCard: ({
|
||||
project,
|
||||
onSelect,
|
||||
node,
|
||||
nodeNameFallback,
|
||||
}: {
|
||||
project: ProjectInfo;
|
||||
onSelect: (p: ProjectInfo) => void;
|
||||
node?: { id: string; name: string };
|
||||
nodeNameFallback?: string;
|
||||
}) => (
|
||||
<div
|
||||
data-testid={`project-card-${project.id}`}
|
||||
data-node={node?.name ?? nodeNameFallback ?? "none"}
|
||||
onClick={() => onSelect(project)}
|
||||
>
|
||||
{project.name}
|
||||
{node && <span className="node-badge">{node.name}</span>}
|
||||
{nodeNameFallback && !node && <span className="node-badge">{nodeNameFallback}</span>}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
@@ -62,7 +84,7 @@ vi.mock("../ProjectGridSkeleton", () => ({
|
||||
ProjectGridSkeleton: () => <div data-testid="project-grid-skeleton">Loading...</div>,
|
||||
}));
|
||||
|
||||
function makeProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
|
||||
function makeProject(overrides: Partial<ProjectInfoWithSource> = {}): ProjectInfoWithSource {
|
||||
return {
|
||||
id: "proj_abc123",
|
||||
name: "Test Project",
|
||||
@@ -354,6 +376,171 @@ describe("ProjectOverview", () => {
|
||||
expect(erroredTab?.className).toContain("has-errors");
|
||||
});
|
||||
|
||||
describe("FN-1850: Node filter and badges", () => {
|
||||
it("shows node badge for project with node association", () => {
|
||||
const localNode = { id: "node_local", name: "Local", type: "local" as const, status: "online" as const, maxConcurrent: 2, createdAt: "", updatedAt: "" };
|
||||
|
||||
render(
|
||||
<ProjectOverview
|
||||
projects={[makeProject({ id: "proj_1", nodeId: "node_local" })]}
|
||||
nodes={[localNode]}
|
||||
onSelectProject={noop}
|
||||
onAddProject={noop}
|
||||
onPauseProject={noop}
|
||||
onResumeProject={noop}
|
||||
onRemoveProject={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
const card = screen.getByTestId("project-card-proj_1");
|
||||
expect(card.getAttribute("data-node")).toBe("Local");
|
||||
});
|
||||
|
||||
it("renders node name fallback for remote projects without local node object", () => {
|
||||
// Remote project with _sourceNodeName but no matching local node
|
||||
render(
|
||||
<ProjectOverview
|
||||
projects={[
|
||||
makeProject({
|
||||
id: "proj_remote",
|
||||
nodeId: "node_remote_abc",
|
||||
_sourceNodeName: "Remote Alpha",
|
||||
}),
|
||||
]}
|
||||
nodes={[]} // No matching local node
|
||||
onSelectProject={noop}
|
||||
onAddProject={noop}
|
||||
onPauseProject={noop}
|
||||
onResumeProject={noop}
|
||||
onRemoveProject={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
const card = screen.getByTestId("project-card-proj_remote");
|
||||
expect(card.getAttribute("data-node")).toBe("Remote Alpha");
|
||||
});
|
||||
|
||||
it("shows node filter dropdown when projects have multiple nodes", () => {
|
||||
render(
|
||||
<ProjectOverview
|
||||
projects={[
|
||||
makeProject({ id: "proj_1", nodeId: "node_1" }),
|
||||
makeProject({ id: "proj_2", nodeId: "node_2" }),
|
||||
]}
|
||||
nodes={[
|
||||
{ id: "node_1", name: "Node One", type: "remote" as const, status: "online" as const, maxConcurrent: 4, createdAt: "", updatedAt: "" },
|
||||
{ id: "node_2", name: "Node Two", type: "remote" as const, status: "online" as const, maxConcurrent: 4, createdAt: "", updatedAt: "" },
|
||||
]}
|
||||
onSelectProject={noop}
|
||||
onAddProject={noop}
|
||||
onPauseProject={noop}
|
||||
onResumeProject={noop}
|
||||
onRemoveProject={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
// Node filter dropdown should be present
|
||||
expect(screen.getByLabelText("Filter by node")).toBeDefined();
|
||||
});
|
||||
|
||||
it("node filter dropdown filters projects by node", async () => {
|
||||
render(
|
||||
<ProjectOverview
|
||||
projects={[
|
||||
makeProject({ id: "proj_1", name: "Project One", nodeId: "node_alpha" }),
|
||||
makeProject({ id: "proj_2", name: "Project Two", nodeId: "node_beta" }),
|
||||
]}
|
||||
nodes={[
|
||||
{ id: "node_alpha", name: "Alpha", type: "remote" as const, status: "online" as const, maxConcurrent: 4, createdAt: "", updatedAt: "" },
|
||||
{ id: "node_beta", name: "Beta", type: "remote" as const, status: "online" as const, maxConcurrent: 4, createdAt: "", updatedAt: "" },
|
||||
]}
|
||||
onSelectProject={noop}
|
||||
onAddProject={noop}
|
||||
onPauseProject={noop}
|
||||
onResumeProject={noop}
|
||||
onRemoveProject={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
// Initially shows both projects
|
||||
expect(screen.getByTestId("project-card-proj_1")).toBeDefined();
|
||||
expect(screen.getByTestId("project-card-proj_2")).toBeDefined();
|
||||
|
||||
// Select "Alpha" from the node filter
|
||||
const nodeFilter = screen.getByLabelText("Filter by node");
|
||||
fireEvent.change(nodeFilter, { target: { value: "node_alpha" } });
|
||||
|
||||
// Should only show Alpha project
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("project-card-proj_1")).toBeDefined();
|
||||
});
|
||||
expect(screen.queryByTestId("project-card-proj_2")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows nodes stat in header when projects span multiple nodes", () => {
|
||||
render(
|
||||
<ProjectOverview
|
||||
projects={[
|
||||
makeProject({ id: "proj_1", nodeId: "node_1" }),
|
||||
makeProject({ id: "proj_2", nodeId: "node_2" }),
|
||||
]}
|
||||
nodes={[
|
||||
{ id: "node_1", name: "Node One", type: "remote" as const, status: "online" as const, maxConcurrent: 4, createdAt: "", updatedAt: "" },
|
||||
{ id: "node_2", name: "Node Two", type: "remote" as const, status: "online" as const, maxConcurrent: 4, createdAt: "", updatedAt: "" },
|
||||
]}
|
||||
onSelectProject={noop}
|
||||
onAddProject={noop}
|
||||
onPauseProject={noop}
|
||||
onResumeProject={noop}
|
||||
onRemoveProject={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
// Nodes stat should be visible
|
||||
const nodesStats = screen.getByText("Nodes").closest(".project-stat__content");
|
||||
expect(nodesStats?.querySelector(".project-stat__value")?.textContent).toBe("2");
|
||||
});
|
||||
|
||||
it("does not show node filter when all projects are local", () => {
|
||||
render(
|
||||
<ProjectOverview
|
||||
projects={[
|
||||
makeProject({ id: "proj_1" }), // No nodeId - local project
|
||||
makeProject({ id: "proj_2" }),
|
||||
]}
|
||||
nodes={[]}
|
||||
onSelectProject={noop}
|
||||
onAddProject={noop}
|
||||
onPauseProject={noop}
|
||||
onResumeProject={noop}
|
||||
onRemoveProject={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
// Node filter should not be present
|
||||
expect(screen.queryByLabelText("Filter by node")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not show nodes stat when only one node (or no node ID)", () => {
|
||||
render(
|
||||
<ProjectOverview
|
||||
projects={[
|
||||
makeProject({ id: "proj_1" }), // No nodeId
|
||||
]}
|
||||
nodes={[]}
|
||||
onSelectProject={noop}
|
||||
onAddProject={noop}
|
||||
onPauseProject={noop}
|
||||
onResumeProject={noop}
|
||||
onRemoveProject={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
// Nodes stat should not be present
|
||||
expect(screen.queryByText("Nodes")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mobile responsive structure", () => {
|
||||
it("renders overview with correct class structure for mobile CSS targets", () => {
|
||||
const { container } = render(
|
||||
@@ -436,6 +623,30 @@ describe("ProjectOverview", () => {
|
||||
expect(container.querySelector(".project-sort-select")).not.toBeNull();
|
||||
expect(screen.getByLabelText("Sort projects")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders node filter select with correct classes for mobile", () => {
|
||||
const { container } = render(
|
||||
<ProjectOverview
|
||||
projects={[
|
||||
makeProject({ id: "proj_1", nodeId: "node_1" }),
|
||||
makeProject({ id: "proj_2", nodeId: "node_2" }),
|
||||
]}
|
||||
nodes={[
|
||||
{ id: "node_1", name: "Node One", type: "remote" as const, status: "online" as const, maxConcurrent: 4, createdAt: "", updatedAt: "" },
|
||||
{ id: "node_2", name: "Node Two", type: "remote" as const, status: "online" as const, maxConcurrent: 4, createdAt: "", updatedAt: "" },
|
||||
]}
|
||||
onSelectProject={noop}
|
||||
onAddProject={noop}
|
||||
onPauseProject={noop}
|
||||
onResumeProject={noop}
|
||||
onRemoveProject={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.querySelector(".project-node-filter")).not.toBeNull();
|
||||
expect(container.querySelector(".project-node-filter-select")).not.toBeNull();
|
||||
expect(screen.getByLabelText("Filter by node")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FN-1734: Health polling scroll position regression", () => {
|
||||
|
||||
@@ -2,16 +2,16 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { useProjects } from "../useProjects";
|
||||
import * as api from "../../api";
|
||||
import type { ProjectInfo } from "../../api";
|
||||
import type { ProjectInfo, ProjectInfoWithSource } from "../../api";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchProjects: vi.fn(),
|
||||
fetchProjectsAcrossNodes: vi.fn(),
|
||||
registerProject: vi.fn(),
|
||||
unregisterProject: vi.fn(),
|
||||
updateProject: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchProjects = vi.mocked(api.fetchProjects);
|
||||
const mockFetchProjectsAcrossNodes = vi.mocked(api.fetchProjectsAcrossNodes);
|
||||
const mockUpdateProject = vi.mocked(api.updateProject);
|
||||
const mockRegisterProject = vi.mocked(api.registerProject);
|
||||
const mockUnregisterProject = vi.mocked(api.unregisterProject);
|
||||
@@ -24,7 +24,7 @@ async function flushPromises(): Promise<void> {
|
||||
describe("useProjects", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
mockFetchProjects.mockReset();
|
||||
mockFetchProjectsAcrossNodes.mockReset();
|
||||
mockUpdateProject.mockReset();
|
||||
mockRegisterProject.mockReset();
|
||||
mockUnregisterProject.mockReset();
|
||||
@@ -45,7 +45,6 @@ describe("useProjects", () => {
|
||||
if (originalVisibilityState) {
|
||||
Object.defineProperty(document, "visibilityState", originalVisibilityState);
|
||||
} else {
|
||||
|
||||
delete (document as any).visibilityState;
|
||||
}
|
||||
});
|
||||
@@ -68,7 +67,7 @@ describe("useProjects", () => {
|
||||
it("refetches projects when visibility changes from hidden to visible", async () => {
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||
|
||||
const initialProject: ProjectInfo = {
|
||||
const initialProject: ProjectInfoWithSource = {
|
||||
id: "proj_001",
|
||||
name: "Initial Project",
|
||||
path: "/initial/path",
|
||||
@@ -77,7 +76,7 @@ describe("useProjects", () => {
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
const refreshedProject: ProjectInfo = {
|
||||
const refreshedProject: ProjectInfoWithSource = {
|
||||
id: "proj_001",
|
||||
name: "Updated Project",
|
||||
path: "/initial/path",
|
||||
@@ -87,7 +86,7 @@ describe("useProjects", () => {
|
||||
updatedAt: "2026-01-02T00:00:00.000Z",
|
||||
};
|
||||
|
||||
mockFetchProjects.mockResolvedValueOnce([initialProject]).mockResolvedValueOnce([refreshedProject]);
|
||||
mockFetchProjectsAcrossNodes.mockResolvedValueOnce([initialProject]).mockResolvedValueOnce([refreshedProject]);
|
||||
|
||||
const { result } = renderHook(() => useProjects());
|
||||
|
||||
@@ -110,11 +109,11 @@ describe("useProjects", () => {
|
||||
});
|
||||
|
||||
expect(result.current.projects[0].name).toBe("Updated Project");
|
||||
expect(mockFetchProjects).toHaveBeenCalledTimes(2);
|
||||
expect(mockFetchProjectsAcrossNodes).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not refetch when visibility changes to hidden", async () => {
|
||||
const initialProject: ProjectInfo = {
|
||||
const initialProject: ProjectInfoWithSource = {
|
||||
id: "proj_001",
|
||||
name: "Test Project",
|
||||
path: "/test/path",
|
||||
@@ -123,7 +122,7 @@ describe("useProjects", () => {
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
mockFetchProjects.mockResolvedValueOnce([initialProject]);
|
||||
mockFetchProjectsAcrossNodes.mockResolvedValueOnce([initialProject]);
|
||||
|
||||
renderHook(() => useProjects());
|
||||
|
||||
@@ -131,18 +130,18 @@ describe("useProjects", () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
mockFetchProjects.mockClear();
|
||||
mockFetchProjectsAcrossNodes.mockClear();
|
||||
|
||||
setVisibilityState("hidden");
|
||||
await dispatchVisibilityChange();
|
||||
|
||||
expect(mockFetchProjects).not.toHaveBeenCalled();
|
||||
expect(mockFetchProjectsAcrossNodes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("debounces rapid visibility changes (minimum 1 second between fetches)", async () => {
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||
|
||||
const initialProject: ProjectInfo = {
|
||||
const initialProject: ProjectInfoWithSource = {
|
||||
id: "proj_001",
|
||||
name: "Test Project",
|
||||
path: "/test/path",
|
||||
@@ -151,7 +150,7 @@ describe("useProjects", () => {
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
mockFetchProjects.mockResolvedValue([initialProject]);
|
||||
mockFetchProjectsAcrossNodes.mockResolvedValue([initialProject]);
|
||||
|
||||
renderHook(() => useProjects());
|
||||
|
||||
@@ -159,7 +158,7 @@ describe("useProjects", () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
mockFetchProjects.mockClear();
|
||||
mockFetchProjectsAcrossNodes.mockClear();
|
||||
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:01.100Z"));
|
||||
setVisibilityState("hidden");
|
||||
@@ -168,7 +167,7 @@ describe("useProjects", () => {
|
||||
setVisibilityState("visible");
|
||||
await dispatchVisibilityChange();
|
||||
|
||||
expect(mockFetchProjects).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetchProjectsAcrossNodes).toHaveBeenCalledTimes(1);
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
setVisibilityState("hidden");
|
||||
@@ -178,7 +177,7 @@ describe("useProjects", () => {
|
||||
await dispatchVisibilityChange();
|
||||
}
|
||||
|
||||
expect(mockFetchProjects).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetchProjectsAcrossNodes).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:02.200Z"));
|
||||
setVisibilityState("hidden");
|
||||
@@ -187,18 +186,18 @@ describe("useProjects", () => {
|
||||
setVisibilityState("visible");
|
||||
await dispatchVisibilityChange();
|
||||
|
||||
expect(mockFetchProjects).toHaveBeenCalledTimes(2);
|
||||
expect(mockFetchProjectsAcrossNodes).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("cleans up visibility change listener on unmount", async () => {
|
||||
mockFetchProjects.mockResolvedValueOnce([]);
|
||||
mockFetchProjectsAcrossNodes.mockResolvedValueOnce([]);
|
||||
|
||||
const removeEventListenerSpy = vi.spyOn(document, "removeEventListener");
|
||||
|
||||
const { unmount } = renderHook(() => useProjects());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchProjects).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetchProjectsAcrossNodes).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
unmount();
|
||||
@@ -210,8 +209,8 @@ describe("useProjects", () => {
|
||||
});
|
||||
|
||||
describe("basic functionality", () => {
|
||||
it("fetches projects on mount", async () => {
|
||||
const mockProjects: ProjectInfo[] = [
|
||||
it("fetches projects on mount using cross-node endpoint", async () => {
|
||||
const mockProjects: ProjectInfoWithSource[] = [
|
||||
{
|
||||
id: "proj_001",
|
||||
name: "Test Project",
|
||||
@@ -222,7 +221,7 @@ describe("useProjects", () => {
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
mockFetchProjects.mockResolvedValueOnce(mockProjects);
|
||||
mockFetchProjectsAcrossNodes.mockResolvedValueOnce(mockProjects);
|
||||
|
||||
const { result } = renderHook(() => useProjects());
|
||||
|
||||
@@ -236,7 +235,7 @@ describe("useProjects", () => {
|
||||
});
|
||||
|
||||
it("handles errors gracefully", async () => {
|
||||
mockFetchProjects.mockRejectedValueOnce(new Error("Failed to fetch"));
|
||||
mockFetchProjectsAcrossNodes.mockRejectedValueOnce(new Error("Failed to fetch"));
|
||||
|
||||
const { result } = renderHook(() => useProjects());
|
||||
|
||||
@@ -258,7 +257,7 @@ describe("useProjects", () => {
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
mockFetchProjects.mockResolvedValueOnce([]);
|
||||
mockFetchProjectsAcrossNodes.mockResolvedValueOnce([]);
|
||||
mockRegisterProject.mockResolvedValueOnce(newProject);
|
||||
|
||||
const { result } = renderHook(() => useProjects());
|
||||
@@ -278,7 +277,7 @@ describe("useProjects", () => {
|
||||
});
|
||||
|
||||
it("unregister removes project optimistically", async () => {
|
||||
const mockProjects: ProjectInfo[] = [
|
||||
const mockProjects: ProjectInfoWithSource[] = [
|
||||
{
|
||||
id: "proj_001",
|
||||
name: "Test Project",
|
||||
@@ -289,7 +288,7 @@ describe("useProjects", () => {
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
mockFetchProjects.mockResolvedValueOnce(mockProjects);
|
||||
mockFetchProjectsAcrossNodes.mockResolvedValueOnce(mockProjects);
|
||||
mockUnregisterProject.mockResolvedValueOnce(undefined);
|
||||
|
||||
const { result } = renderHook(() => useProjects());
|
||||
@@ -308,7 +307,7 @@ describe("useProjects", () => {
|
||||
});
|
||||
|
||||
it("update modifies project optimistically", async () => {
|
||||
const mockProjects: ProjectInfo[] = [
|
||||
const mockProjects: ProjectInfoWithSource[] = [
|
||||
{
|
||||
id: "proj_001",
|
||||
name: "Test Project",
|
||||
@@ -323,7 +322,7 @@ describe("useProjects", () => {
|
||||
...mockProjects[0],
|
||||
name: "Updated Name",
|
||||
};
|
||||
mockFetchProjects.mockResolvedValueOnce(mockProjects);
|
||||
mockFetchProjectsAcrossNodes.mockResolvedValueOnce(mockProjects);
|
||||
mockUpdateProject.mockResolvedValueOnce(updatedProject);
|
||||
|
||||
const { result } = renderHook(() => useProjects());
|
||||
@@ -342,7 +341,7 @@ describe("useProjects", () => {
|
||||
});
|
||||
|
||||
it("refresh manually refetches projects", async () => {
|
||||
const initialProject: ProjectInfo = {
|
||||
const initialProject: ProjectInfoWithSource = {
|
||||
id: "proj_001",
|
||||
name: "Initial",
|
||||
path: "/test/path",
|
||||
@@ -351,11 +350,11 @@ describe("useProjects", () => {
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
const refreshedProject: ProjectInfo = {
|
||||
const refreshedProject: ProjectInfoWithSource = {
|
||||
...initialProject,
|
||||
name: "Refreshed",
|
||||
};
|
||||
mockFetchProjects.mockResolvedValueOnce([initialProject]).mockResolvedValueOnce([refreshedProject]);
|
||||
mockFetchProjectsAcrossNodes.mockResolvedValueOnce([initialProject]).mockResolvedValueOnce([refreshedProject]);
|
||||
|
||||
const { result } = renderHook(() => useProjects());
|
||||
|
||||
@@ -371,5 +370,47 @@ describe("useProjects", () => {
|
||||
|
||||
expect(result.current.projects[0].name).toBe("Refreshed");
|
||||
});
|
||||
|
||||
it("returns projects with _sourceNodeName from aggregated endpoint", async () => {
|
||||
const mockProjects: ProjectInfoWithSource[] = [
|
||||
{
|
||||
id: "proj_local",
|
||||
name: "Local Project",
|
||||
path: "/local/path",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "proj_remote",
|
||||
name: "Remote Project",
|
||||
path: "/remote/path",
|
||||
status: "active",
|
||||
isolationMode: "child-process",
|
||||
nodeId: "node_alpha",
|
||||
_sourceNodeName: "Alpha Node",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
mockFetchProjectsAcrossNodes.mockResolvedValueOnce(mockProjects);
|
||||
|
||||
const { result } = renderHook(() => useProjects());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.projects).toHaveLength(2);
|
||||
|
||||
const localProject = result.current.projects.find((p) => p.id === "proj_local");
|
||||
expect(localProject?._sourceNodeName).toBeUndefined();
|
||||
expect(localProject?.nodeId).toBeUndefined();
|
||||
|
||||
const remoteProject = result.current.projects.find((p) => p.id === "proj_remote");
|
||||
expect(remoteProject?._sourceNodeName).toBe("Alpha Node");
|
||||
expect(remoteProject?.nodeId).toBe("node_alpha");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import type { ProjectInfo } from "../api";
|
||||
import {
|
||||
fetchProjects,
|
||||
fetchProjectsAcrossNodes,
|
||||
registerProject,
|
||||
unregisterProject,
|
||||
updateProject,
|
||||
type ProjectCreateInput,
|
||||
type ProjectInfoWithSource,
|
||||
} from "../api";
|
||||
|
||||
export interface UseProjectsResult {
|
||||
/** List of all registered projects */
|
||||
projects: ProjectInfo[];
|
||||
/** List of all registered projects (local + remote) */
|
||||
projects: ProjectInfoWithSource[];
|
||||
/** Loading state for initial fetch */
|
||||
loading: boolean;
|
||||
/** Error message if fetch failed */
|
||||
@@ -35,7 +36,7 @@ const VISIBILITY_REFRESH_DEBOUNCE_MS = 1000;
|
||||
* Provides optimistic updates for UI responsiveness.
|
||||
*/
|
||||
export function useProjects(): UseProjectsResult {
|
||||
const [projects, setProjects] = useState<ProjectInfo[]>([]);
|
||||
const [projects, setProjects] = useState<ProjectInfoWithSource[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
@@ -44,7 +45,7 @@ export function useProjects(): UseProjectsResult {
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const data = await fetchProjects();
|
||||
const data = await fetchProjectsAcrossNodes();
|
||||
setProjects(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch projects");
|
||||
@@ -59,7 +60,7 @@ export function useProjects(): UseProjectsResult {
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchProjects();
|
||||
const data = await fetchProjectsAcrossNodes();
|
||||
if (!cancelled) {
|
||||
setProjects(data);
|
||||
setError(null);
|
||||
|
||||
@@ -170,6 +170,9 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
// (e.g., sseEnabled flipped to false during a pending reconnect timer in sse-bus).
|
||||
let active = true;
|
||||
|
||||
// Guard against stale callbacks: when sseEnabled flips false or the
|
||||
// effect unmounts, these handlers must not fire refreshTasks into a
|
||||
// missions-only view where the SSE should be inactive.
|
||||
const handleCreated = (e: MessageEvent) => {
|
||||
if (isStale()) return;
|
||||
const task = normalizeTask(JSON.parse(e.data) as Task);
|
||||
@@ -267,6 +270,8 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
"task:deleted": handleDeleted,
|
||||
"task:merged": handleMerged,
|
||||
},
|
||||
// Guard onReconnect against stale SSE callbacks: do not call refreshTasks
|
||||
// if the SSE was disabled or the effect unmounted while reconnect was pending.
|
||||
onReconnect: () => {
|
||||
if (!active) return;
|
||||
if (isStale()) return;
|
||||
|
||||
@@ -55,6 +55,12 @@ function forceReconnect(channel: Channel): void {
|
||||
if (channel.closed) return;
|
||||
if (channel.subscribers.size === 0 || channel.reconnectTimer) return;
|
||||
|
||||
// Guard against calling onReconnect callbacks for a channel that has been
|
||||
// closed while the heartbeat timer fired. This prevents stale SSE events from
|
||||
// firing into unsubscribed/mounted-out consumers during rapid view switches.
|
||||
const ch = channels.get(channel.url);
|
||||
if (!ch || ch !== channel) return;
|
||||
|
||||
// A teardown means events may have been missed while the stream was
|
||||
// down. Signal resync to each subscriber so they can refetch
|
||||
// authoritative state.
|
||||
@@ -63,7 +69,12 @@ function forceReconnect(channel: Channel): void {
|
||||
channel.reconnectTimer = setTimeout(() => {
|
||||
channel.reconnectTimer = null;
|
||||
if (channel.closed) return;
|
||||
if (channel.subscribers.size > 0) openChannel(channel);
|
||||
// Re-check after timer fires — the channel may have been closed or
|
||||
// the subscription count changed during the delay.
|
||||
const current = channels.get(channel.url);
|
||||
if (current && current === channel && channel.subscribers.size > 0) {
|
||||
openChannel(channel);
|
||||
}
|
||||
}, RECONNECT_DELAY_MS);
|
||||
}
|
||||
|
||||
|
||||
@@ -2399,6 +2399,42 @@ body {
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
/* --- Node Filter Dropdown --- */
|
||||
.project-node-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.project-node-filter-select {
|
||||
appearance: none;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
padding: 6px 28px 6px 10px;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-primary);
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
.project-node-filter-select:hover {
|
||||
border-color: var(--text-dim);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.project-node-filter-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--todo);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
/* --- Project Grid --- */
|
||||
.project-grid {
|
||||
display: grid;
|
||||
@@ -2709,6 +2745,16 @@ body {
|
||||
margin-left: var(--space-sm);
|
||||
}
|
||||
|
||||
.project-node-filter {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.project-node-filter-select {
|
||||
flex: 1;
|
||||
margin-left: var(--space-sm);
|
||||
}
|
||||
|
||||
/* Grid */
|
||||
.project-grid {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
Reference in New Issue
Block a user