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:
Fusion
2026-04-18 03:08:00 -07:00
committed by gsxdsm
parent 76123c9538
commit 53babb3524
14 changed files with 1674 additions and 55 deletions

View File

@@ -0,0 +1,439 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import { get } from "../test-request.js";
// ── Mock @fusion/core for project routes ─────────────────────────────────
const mockInit = vi.fn().mockResolvedValue(undefined);
const mockClose = vi.fn().mockResolvedValue(undefined);
const mockListProjects = vi.fn();
const mockListNodes = vi.fn();
const mockReconcileProjectStatuses = vi.fn().mockResolvedValue(undefined);
const mockChatStoreInit = vi.fn().mockResolvedValue(undefined);
const mockAgentStoreInit = vi.fn().mockResolvedValue(undefined);
// Store original fetch for use in tests
const originalFetch = globalThis.fetch;
vi.mock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
return {
...actual,
CentralCore: class MockCentralCore {
init = mockInit;
close = mockClose;
listProjects = mockListProjects;
listNodes = mockListNodes;
reconcileProjectStatuses = mockReconcileProjectStatuses;
},
ChatStore: class MockChatStore {
init = mockChatStoreInit;
},
AgentStore: class MockAgentStore {
init = mockAgentStoreInit;
},
};
});
// ── Mock Store ────────────────────────────────────────────────────────
class MockStore extends EventEmitter {
getRootDir(): string {
return "/tmp/fn-1850-test";
}
getFusionDir(): string {
return "/tmp/fn-1850-test/.fusion";
}
getDatabase() {
return {
exec: vi.fn(),
prepare: vi.fn().mockReturnValue({
run: vi.fn().mockReturnValue({ changes: 0 }),
get: vi.fn(),
all: vi.fn().mockReturnValue([]),
}),
};
}
}
// ── Test helpers ──────────────────────────────────────────────────────
function createMockProject(overrides: Record<string, unknown> = {}) {
return {
id: "proj_local001",
name: "Local Project",
path: "/projects/local",
status: "active" as const,
isolationMode: "in-process" as const,
nodeId: undefined,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
function createMockRemoteNode(overrides: Record<string, unknown> = {}) {
return {
id: "node_remote001",
name: "Remote Node",
type: "remote" as const,
status: "online" as const,
url: "https://remote-node.example.com",
apiKey: "test-api-key-123",
maxConcurrent: 4,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
function createMockRemoteProject(overrides: Record<string, unknown> = {}) {
return {
id: "proj_remote001",
name: "Remote Project",
path: "/projects/remote",
status: "active" as const,
isolationMode: "child-process" as const,
nodeId: "node_remote001",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
// ── Test setup ─────────────────────────────────────────────────────────
describe("GET /api/projects/across-nodes", () => {
let store: MockStore;
let app: (req: import("node:http").IncomingMessage, res: import("node:http").ServerResponse) => void;
beforeEach(async () => {
vi.clearAllMocks();
store = new MockStore();
const { createServer } = await import("../server.js");
app = createServer(store as unknown as Parameters<typeof createServer>[0] extends { store: infer S } ? S : never);
});
afterEach(() => {
vi.restoreAllMocks();
// Restore original fetch
globalThis.fetch = originalFetch;
});
it("returns local projects when no remote nodes exist", async () => {
const localProjects = [
createMockProject({ id: "proj_001", name: "Local Project 1" }),
createMockProject({ id: "proj_002", name: "Local Project 2" }),
];
mockListProjects.mockResolvedValueOnce(localProjects);
mockListNodes.mockResolvedValueOnce([]); // No remote nodes
const response = await get(app, "/api/projects/across-nodes");
expect(response.status).toBe(200);
const body = response.body as Array<{ id: string }>;
expect(body).toHaveLength(2);
expect(body[0].id).toBe("proj_001");
expect(body[1].id).toBe("proj_002");
});
it("returns merged projects when remote nodes are online", async () => {
const localProjects = [
createMockProject({ id: "proj_local001", name: "Local Project" }),
];
const remoteNode = createMockRemoteNode();
const remoteProjects = [
createMockRemoteProject({ id: "proj_remote001", name: "Remote Project" }),
];
mockListProjects.mockResolvedValueOnce(localProjects);
mockListNodes.mockResolvedValueOnce([remoteNode]);
// Mock fetch for remote node
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => remoteProjects,
});
globalThis.fetch = mockFetch;
const response = await get(app, "/api/projects/across-nodes");
expect(response.status).toBe(200);
const body = response.body as Array<{ id: string; nodeId?: string; _sourceNodeName?: string }>;
expect(body).toHaveLength(2);
// Check local project
const localProject = body.find((p) => p.id === "proj_local001");
expect(localProject).toBeDefined();
expect(localProject?.nodeId).toBeUndefined();
// Check remote project was tagged with node info
const remoteProject = body.find((p) => p.id === "proj_remote001");
expect(remoteProject).toBeDefined();
expect(remoteProject?.nodeId).toBe(remoteNode.id);
expect(remoteProject?._sourceNodeName).toBe(remoteNode.name);
});
it("skips unreachable remote nodes gracefully", async () => {
const localProjects = [
createMockProject({ id: "proj_local001", name: "Local Project" }),
];
const remoteNode = createMockRemoteNode({ id: "node_unreachable", name: "Unreachable Node" });
mockListProjects.mockResolvedValueOnce(localProjects);
mockListNodes.mockResolvedValueOnce([remoteNode]);
// Mock fetch that throws an error (simulating unreachable node)
const mockFetch = vi.fn().mockRejectedValue(new Error("Network error"));
globalThis.fetch = mockFetch;
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const response = await get(app, "/api/projects/across-nodes");
expect(response.status).toBe(200);
const body = response.body as Array<{ id: string }>;
// Should still return local projects
expect(body).toHaveLength(1);
expect(body[0].id).toBe("proj_local001");
// Should have logged a warning
expect(consoleWarnSpy).toHaveBeenCalled();
expect(consoleWarnSpy.mock.calls[0][0]).toContain("[projects:across-nodes]");
consoleWarnSpy.mockRestore();
});
it("skips offline remote nodes", async () => {
const localProjects = [
createMockProject({ id: "proj_local001", name: "Local Project" }),
];
const offlineNode = createMockRemoteNode({
id: "node_offline",
name: "Offline Node",
status: "offline",
});
mockListProjects.mockResolvedValueOnce(localProjects);
mockListNodes.mockResolvedValueOnce([offlineNode]);
// Mock fetch - should NOT be called
const mockFetch = vi.fn();
globalThis.fetch = mockFetch;
const response = await get(app, "/api/projects/across-nodes");
expect(response.status).toBe(200);
const body = response.body as Array<{ id: string }>;
expect(body).toHaveLength(1);
// Fetch should not have been called for offline node
expect(mockFetch).not.toHaveBeenCalled();
});
it("skips nodes without URLs", async () => {
const localProjects = [
createMockProject({ id: "proj_local001", name: "Local Project" }),
];
const nodeWithoutUrl = createMockRemoteNode({
id: "node_no_url",
name: "Node Without URL",
url: undefined,
});
mockListProjects.mockResolvedValueOnce(localProjects);
mockListNodes.mockResolvedValueOnce([nodeWithoutUrl]);
// Mock fetch - should NOT be called
const mockFetch = vi.fn();
globalThis.fetch = mockFetch;
const response = await get(app, "/api/projects/across-nodes");
expect(response.status).toBe(200);
const body = response.body as Array<{ id: string }>;
expect(body).toHaveLength(1);
// Fetch should not have been called for node without URL
expect(mockFetch).not.toHaveBeenCalled();
});
it("tags remote projects with nodeId and sourceNodeName", async () => {
const localProjects: ReturnType<typeof createMockProject>[] = [];
const remoteNode1 = createMockRemoteNode({
id: "node_alpha",
name: "Alpha Node",
url: "https://alpha.example.com",
});
const remoteNode2 = createMockRemoteNode({
id: "node_beta",
name: "Beta Node",
url: "https://beta.example.com",
});
const remoteProjectsAlpha = [
createMockProject({ id: "proj_a1", name: "Alpha Project 1" }),
createMockProject({ id: "proj_a2", name: "Alpha Project 2" }),
];
const remoteProjectsBeta = [
createMockProject({ id: "proj_b1", name: "Beta Project" }),
];
mockListProjects.mockResolvedValueOnce(localProjects);
mockListNodes.mockResolvedValueOnce([remoteNode1, remoteNode2]);
// Mock fetch for multiple nodes
let callCount = 0;
const mockFetch = vi.fn().mockImplementation(() => {
callCount++;
if (callCount === 1) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => remoteProjectsAlpha,
});
} else {
return Promise.resolve({
ok: true,
status: 200,
json: async () => remoteProjectsBeta,
});
}
});
globalThis.fetch = mockFetch;
const response = await get(app, "/api/projects/across-nodes");
expect(response.status).toBe(200);
const body = response.body as Array<{ id: string; nodeId?: string; _sourceNodeName?: string }>;
expect(body).toHaveLength(3);
// Check Alpha node projects
const alphaProject1 = body.find((p) => p.id === "proj_a1");
expect(alphaProject1?.nodeId).toBe("node_alpha");
expect(alphaProject1?._sourceNodeName).toBe("Alpha Node");
const alphaProject2 = body.find((p) => p.id === "proj_a2");
expect(alphaProject2?.nodeId).toBe("node_alpha");
expect(alphaProject2?._sourceNodeName).toBe("Alpha Node");
// Check Beta node project
const betaProject = body.find((p) => p.id === "proj_b1");
expect(betaProject?.nodeId).toBe("node_beta");
expect(betaProject?._sourceNodeName).toBe("Beta Node");
});
it("handles HTTP errors from remote nodes gracefully", async () => {
const localProjects = [
createMockProject({ id: "proj_local001", name: "Local Project" }),
];
const remoteNode = createMockRemoteNode({
id: "node_error",
name: "Error Node",
url: "https://error.example.com",
});
mockListProjects.mockResolvedValueOnce(localProjects);
mockListNodes.mockResolvedValueOnce([remoteNode]);
// Mock fetch that returns an error response
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
statusText: "Internal Server Error",
});
globalThis.fetch = mockFetch;
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const response = await get(app, "/api/projects/across-nodes");
expect(response.status).toBe(200);
const body = response.body as Array<{ id: string }>;
// Should still return local projects
expect(body).toHaveLength(1);
// Should have logged a warning
expect(consoleWarnSpy).toHaveBeenCalled();
consoleWarnSpy.mockRestore();
});
it("handles non-JSON responses from remote nodes gracefully", async () => {
const localProjects = [
createMockProject({ id: "proj_local001", name: "Local Project" }),
];
const remoteNode = createMockRemoteNode({
id: "node_bad_json",
name: "Bad JSON Node",
url: "https://badjson.example.com",
});
mockListProjects.mockResolvedValueOnce(localProjects);
mockListNodes.mockResolvedValueOnce([remoteNode]);
// Mock fetch that returns non-JSON response
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => {
throw new SyntaxError("Unexpected token");
},
});
globalThis.fetch = mockFetch;
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const response = await get(app, "/api/projects/across-nodes");
expect(response.status).toBe(200);
const body = response.body as Array<{ id: string }>;
// Should still return local projects
expect(body).toHaveLength(1);
// Should have logged a warning
expect(consoleWarnSpy).toHaveBeenCalled();
consoleWarnSpy.mockRestore();
});
it("fetches from multiple remote nodes in parallel", async () => {
const localProjects: ReturnType<typeof createMockProject>[] = [];
const remoteNode1 = createMockRemoteNode({
id: "node_p1",
name: "Parallel Node 1",
url: "https://p1.example.com",
});
const remoteNode2 = createMockRemoteNode({
id: "node_p2",
name: "Parallel Node 2",
url: "https://p2.example.com",
});
mockListProjects.mockResolvedValueOnce(localProjects);
mockListNodes.mockResolvedValueOnce([remoteNode1, remoteNode2]);
const mockFetch = vi.fn().mockImplementation((url: string) => {
return Promise.resolve({
ok: true,
status: 200,
json: async () => [
createMockProject({ id: `proj_${url.includes("p1") ? "from1" : "from2"}` }),
],
});
});
globalThis.fetch = mockFetch;
const response = await get(app, "/api/projects/across-nodes");
expect(response.status).toBe(200);
const body = response.body as Array<{ id: string }>;
expect(body).toHaveLength(2);
// Both fetches should have been triggered
expect(mockFetch).toHaveBeenCalledTimes(2);
});
});