feat(FN-1838): merge fusion/fn-1838

This commit is contained in:
gsxdsm
2026-04-14 13:30:24 -07:00
parent 051622ae08
commit 70082a8919
5 changed files with 1013 additions and 0 deletions

View File

@@ -0,0 +1,559 @@
/**
* Integration tests for multi-node dashboard functionality.
*
* Tests the full flow of node registration, status management,
* and dashboard display in the multi-node context.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { CentralCore } from "../central-core.js";
import { seedSampleNodes } from "./seed-sample-nodes.js";
import type { NodeConfig, NodeStatus } from "../types.js";
describe("Multi-Node Dashboard", () => {
let tempDir: string;
let central: CentralCore;
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-01T12:00:00.000Z"));
tempDir = mkdtempSync(join(tmpdir(), "kb-multi-node-test-"));
central = new CentralCore(tempDir);
});
afterEach(async () => {
await central.close();
vi.useRealTimers();
vi.restoreAllMocks();
rmSync(tempDir, { recursive: true, force: true });
});
describe("node registration with mixed types", () => {
it("should register remote nodes and list all sorted by name", async () => {
await central.init();
// Note: init() creates a default "local" node automatically
// Register remote nodes only (to avoid duplicate local nodes)
await central.registerNode({
name: "Alpha Remote",
type: "remote",
url: "https://alpha.example.com",
maxConcurrent: 2,
});
await central.registerNode({
name: "Beta Remote",
type: "remote",
url: "https://beta.example.com",
maxConcurrent: 4,
});
await central.registerNode({
name: "Gamma Remote",
type: "remote",
url: "https://gamma.example.com",
maxConcurrent: 8,
});
const nodes = await central.listNodes();
// 1 default local + 3 remote = 4 nodes
expect(nodes).toHaveLength(4);
// Should be sorted alphabetically by name
const names = nodes.map((n) => n.name);
expect(names).toEqual([
"Alpha Remote",
"Beta Remote",
"Gamma Remote",
"local", // auto-created local node
]);
// Verify types
const localNodes = nodes.filter((n) => n.type === "local");
const remoteNodes = nodes.filter((n) => n.type === "remote");
expect(localNodes).toHaveLength(1);
expect(remoteNodes).toHaveLength(3);
});
it("should create 1 local + 5 remote nodes via seed function", async () => {
await central.init();
const nodes = await seedSampleNodes(central);
expect(nodes).toHaveLength(6);
// Verify 1 local node
const localNodes = nodes.filter((n) => n.type === "local");
expect(localNodes).toHaveLength(1);
expect(localNodes[0].name).toBe("local");
expect(localNodes[0].status).toBe("online");
// Verify 5 remote nodes
const remoteNodes = nodes.filter((n) => n.type === "remote");
expect(remoteNodes).toHaveLength(5);
// Verify expected remote nodes exist
const remoteNames = remoteNodes.map((n) => n.name).sort();
expect(remoteNames).toEqual([
"Build Machine",
"Dev Box (John)",
"GPU Cluster",
"QA Environment",
"Staging Server",
]);
});
});
describe("node status transitions", () => {
it("should transition node from offline to online", async () => {
await central.init();
const node = await central.registerNode({
name: "Status Test Node",
type: "local",
maxConcurrent: 2,
});
expect(node.status).toBe("offline");
// Transition to online
const onlineNode = await central.updateNode(node.id, { status: "online" });
expect(onlineNode.status).toBe("online");
// Verify persisted
const fetched = await central.getNode(node.id);
expect(fetched?.status).toBe("online");
});
it("should transition node through multiple statuses", async () => {
await central.init();
const node = await central.registerNode({
name: "Multi Status Node",
type: "remote",
url: "https://multi-status.example.com",
maxConcurrent: 2,
});
// offline -> connecting -> online
let updated = await central.updateNode(node.id, { status: "connecting" });
expect(updated.status).toBe("connecting");
updated = await central.updateNode(node.id, { status: "online" });
expect(updated.status).toBe("online");
// online -> error
updated = await central.updateNode(node.id, { status: "error" });
expect(updated.status).toBe("error");
// error -> offline
updated = await central.updateNode(node.id, { status: "offline" });
expect(updated.status).toBe("offline");
});
it("should handle seed nodes with varied statuses", async () => {
await central.init();
const nodes = await seedSampleNodes(central);
// Verify all expected statuses
const statusMap = new Map<string, NodeStatus>();
for (const node of nodes) {
statusMap.set(node.name, node.status);
}
expect(statusMap.get("local")).toBe("online");
expect(statusMap.get("Staging Server")).toBe("online");
expect(statusMap.get("Build Machine")).toBe("online");
expect(statusMap.get("GPU Cluster")).toBe("offline");
expect(statusMap.get("Dev Box (John)")).toBe("error");
expect(statusMap.get("QA Environment")).toBe("connecting");
});
});
describe("list nodes returns correct type distribution", () => {
it("should return correct local vs remote count", async () => {
await central.init();
// Add via seed which has 1 local + 5 remote
await seedSampleNodes(central);
const nodes = await central.listNodes();
const localNodes = nodes.filter((n) => n.type === "local");
const remoteNodes = nodes.filter((n) => n.type === "remote");
expect(localNodes).toHaveLength(1);
expect(remoteNodes).toHaveLength(5);
expect(nodes).toHaveLength(6);
});
it("should return correct status distribution", async () => {
await central.init();
await seedSampleNodes(central);
const nodes = await central.listNodes();
const online = nodes.filter((n) => n.status === "online").length;
const offline = nodes.filter((n) => n.status === "offline").length;
const error = nodes.filter((n) => n.status === "error").length;
const connecting = nodes.filter((n) => n.status === "connecting").length;
expect(online).toBe(3); // local + 2 remote
expect(offline).toBe(1); // GPU Cluster
expect(error).toBe(1); // Dev Box (John)
expect(connecting).toBe(1); // QA Environment
});
});
describe("concurrent max tracking", () => {
it("should preserve maxConcurrent on registration", async () => {
await central.init();
const node = await central.registerNode({
name: "Max Concurrent Test",
type: "local",
maxConcurrent: 8,
});
expect(node.maxConcurrent).toBe(8);
const fetched = await central.getNode(node.id);
expect(fetched?.maxConcurrent).toBe(8);
});
it("should preserve maxConcurrent on update", async () => {
await central.init();
const node = await central.registerNode({
name: "Max Concurrent Update",
type: "local",
maxConcurrent: 2,
});
const updated = await central.updateNode(node.id, { maxConcurrent: 16 });
expect(updated.maxConcurrent).toBe(16);
const fetched = await central.getNode(node.id);
expect(fetched?.maxConcurrent).toBe(16);
});
it("should preserve maxConcurrent from seed nodes", async () => {
await central.init();
await seedSampleNodes(central);
const nodes = await central.listNodes();
const maxConcurrentMap = new Map<string, number>();
for (const node of nodes) {
maxConcurrentMap.set(node.name, node.maxConcurrent);
}
expect(maxConcurrentMap.get("local")).toBe(4);
expect(maxConcurrentMap.get("Staging Server")).toBe(4);
expect(maxConcurrentMap.get("Build Machine")).toBe(8);
expect(maxConcurrentMap.get("GPU Cluster")).toBe(16);
expect(maxConcurrentMap.get("Dev Box (John)")).toBe(2);
expect(maxConcurrentMap.get("QA Environment")).toBe(4);
});
it("should reject invalid maxConcurrent values", async () => {
await central.init();
// Test zero
await expect(
central.registerNode({
name: "Zero Max",
type: "local",
maxConcurrent: 0,
}),
).rejects.toThrow("maxConcurrent must be >= 1");
// Test negative
await expect(
central.registerNode({
name: "Negative Max",
type: "local",
maxConcurrent: -1,
}),
).rejects.toThrow("maxConcurrent must be >= 1");
// Test Infinity
await expect(
central.registerNode({
name: "Infinity Max",
type: "local",
maxConcurrent: Infinity,
}),
).rejects.toThrow("maxConcurrent must be >= 1");
// Test NaN
await expect(
central.registerNode({
name: "NaN Max",
type: "local",
maxConcurrent: NaN,
}),
).rejects.toThrow("maxConcurrent must be >= 1");
});
});
describe("unregister removes node from listing", () => {
it("should remove node from list after unregister", async () => {
await central.init();
await seedSampleNodes(central);
const nodesBefore = await central.listNodes();
expect(nodesBefore).toHaveLength(6);
// Unregister one remote node
const stagingServer = nodesBefore.find((n) => n.name === "Staging Server");
expect(stagingServer).toBeDefined();
await central.unregisterNode(stagingServer!.id);
const nodesAfter = await central.listNodes();
expect(nodesAfter).toHaveLength(5);
// Verify removed
const names = nodesAfter.map((n) => n.name);
expect(names).not.toContain("Staging Server");
});
it("should remove multiple nodes and leave correct count", async () => {
await central.init();
await seedSampleNodes(central);
// Unregister 2 nodes (GPU Cluster and QA Environment)
const nodes = await central.listNodes();
const gpuCluster = nodes.find((n) => n.name === "GPU Cluster");
const qaEnv = nodes.find((n) => n.name === "QA Environment");
await central.unregisterNode(gpuCluster!.id);
await central.unregisterNode(qaEnv!.id);
const remaining = await central.listNodes();
expect(remaining).toHaveLength(4);
// Verify remaining nodes
const remainingNames = remaining.map((n) => n.name).sort();
expect(remainingNames).toEqual([
"Build Machine",
"Dev Box (John)",
"Staging Server",
"local",
]);
});
it("should be idempotent when unregistering non-existent node", async () => {
await central.init();
await seedSampleNodes(central);
const nodesBefore = await central.listNodes();
// Try to unregister non-existent node
await expect(central.unregisterNode("non_existent_id")).resolves.toBeUndefined();
const nodesAfter = await central.listNodes();
expect(nodesAfter).toHaveLength(nodesBefore.length);
});
});
describe("node name uniqueness enforcement", () => {
it("should reject duplicate node names", async () => {
await central.init();
await central.registerNode({
name: "Unique Name",
type: "local",
maxConcurrent: 2,
});
await expect(
central.registerNode({
name: "Unique Name",
type: "local",
maxConcurrent: 2,
}),
).rejects.toThrow("already exists");
});
it("should allow same name after unregister", async () => {
await central.init();
const node = await central.registerNode({
name: "Reusable Name",
type: "local",
maxConcurrent: 2,
});
await central.unregisterNode(node.id);
// Should be able to register again with same name
const newNode = await central.registerNode({
name: "Reusable Name",
type: "local",
maxConcurrent: 4,
});
expect(newNode.name).toBe("Reusable Name");
});
it("should reject duplicate names during seed idempotency", async () => {
await central.init();
await seedSampleNodes(central);
// Running seed again should update existing nodes, not fail
const nodes = await seedSampleNodes(central);
expect(nodes).toHaveLength(6);
});
});
describe("remote nodes require URL", () => {
it("should reject remote node without URL", async () => {
await central.init();
await expect(
central.registerNode({
name: "Remote Without URL",
type: "remote",
maxConcurrent: 2,
}),
).rejects.toThrow("must include a url");
});
it("should reject remote node with empty URL", async () => {
await central.init();
await expect(
central.registerNode({
name: "Remote With Empty URL",
type: "remote",
url: "",
maxConcurrent: 2,
}),
).rejects.toThrow("must include a url");
});
it("should accept remote node with valid URL", async () => {
await central.init();
const node = await central.registerNode({
name: "Remote With URL",
type: "remote",
url: "https://valid.example.com",
maxConcurrent: 2,
});
expect(node.url).toBe("https://valid.example.com");
expect(node.type).toBe("remote");
});
it("should allow remote node URL update", async () => {
await central.init();
const node = await central.registerNode({
name: "URL Update Test",
type: "remote",
url: "https://old.example.com",
maxConcurrent: 2,
});
const updated = await central.updateNode(node.id, {
url: "https://new.example.com",
});
expect(updated.url).toBe("https://new.example.com");
});
});
describe("local nodes must not have URL/apiKey", () => {
it("should reject local node with URL", async () => {
await central.init();
await expect(
central.registerNode({
name: "Local With URL",
type: "local",
url: "https://should-fail.example.com",
maxConcurrent: 2,
}),
).rejects.toThrow("must not include url or apiKey");
});
it("should reject local node with apiKey", async () => {
await central.init();
await expect(
central.registerNode({
name: "Local With API Key",
type: "local",
apiKey: "secret-key",
maxConcurrent: 2,
}),
).rejects.toThrow("must not include url or apiKey");
});
it("should reject local node with both URL and apiKey", async () => {
await central.init();
await expect(
central.registerNode({
name: "Local With Both",
type: "local",
url: "https://fail.example.com",
apiKey: "secret-key",
maxConcurrent: 2,
}),
).rejects.toThrow("must not include url or apiKey");
});
it("should accept local node without URL or apiKey", async () => {
await central.init();
const node = await central.registerNode({
name: "Valid Local",
type: "local",
maxConcurrent: 2,
});
expect(node.type).toBe("local");
expect(node.url).toBeUndefined();
expect(node.apiKey).toBeUndefined();
});
});
describe("seed function idempotency", () => {
it("should handle multiple seed calls without creating duplicates", async () => {
await central.init();
// First seed
const firstSeed = await seedSampleNodes(central);
expect(firstSeed).toHaveLength(6);
// Second seed - should update existing, not create duplicates
const secondSeed = await seedSampleNodes(central);
expect(secondSeed).toHaveLength(6);
// Verify only 6 nodes exist
const allNodes = await central.listNodes();
expect(allNodes).toHaveLength(6);
});
it("should update existing node statuses on re-seed", async () => {
await central.init();
await seedSampleNodes(central);
// Manually change a status
const gpuCluster = await central.getNodeByName("GPU Cluster");
expect(gpuCluster).toBeDefined();
await central.updateNode(gpuCluster!.id, { status: "online" });
// Re-seed should restore original status
await seedSampleNodes(central);
const updated = await central.getNodeByName("GPU Cluster");
expect(updated?.status).toBe("offline"); // Original status from seed
});
});
});

View File

@@ -0,0 +1,170 @@
/**
* Seed script for populating the central database with sample nodes.
*
* This script creates a realistic set of nodes (1 local + 5 remote) for
* visual testing of the multi-node dashboard.
*
* Usage:
* - Direct execution (seeds real central database):
* npx tsx packages/core/src/__tests__/seed-sample-nodes.ts
*
* - As a module (for tests):
* import { seedSampleNodes } from "./seed-sample-nodes";
* await seedSampleNodes(central);
*/
import { CentralCore } from "../central-core.js";
import type { NodeConfig, NodeStatus } from "../types.js";
import { homedir } from "node:os";
import { join } from "node:path";
/** Sample remote nodes to create for visual testing */
const SAMPLE_REMOTE_NODES = [
{
name: "Staging Server",
url: "https://staging.runfusion.ai",
status: "online" as NodeStatus,
maxConcurrent: 4,
},
{
name: "Build Machine",
url: "https://build.runfusion.ai",
status: "online" as NodeStatus,
maxConcurrent: 8,
},
{
name: "GPU Cluster",
url: "https://gpu.runfusion.ai",
status: "offline" as NodeStatus,
maxConcurrent: 16,
},
{
name: "Dev Box (John)",
url: "http://192.168.1.100:4040",
status: "error" as NodeStatus,
maxConcurrent: 2,
},
{
name: "QA Environment",
url: "https://qa.runfusion.ai",
status: "connecting" as NodeStatus,
maxConcurrent: 4,
},
] as const;
/**
* Seed the central database with sample nodes for visual testing.
*
* @param central - An initialized CentralCore instance
* @returns Array of registered nodes (1 local + up to 5 remote)
*/
export async function seedSampleNodes(central: CentralCore): Promise<NodeConfig[]> {
const nodes: NodeConfig[] = [];
// Ensure central is initialized
if (!central.isInitialized()) {
await central.init();
}
// 1. Get or create the local node (auto-created on init)
const existingNodes = await central.listNodes();
const existingLocal = existingNodes.find((n) => n.type === "local");
let localNode: NodeConfig;
if (existingLocal) {
// Update local node status to online
localNode = await central.updateNode(existingLocal.id, { status: "online" });
} else {
// Create local node
localNode = await central.registerNode({
name: "local",
type: "local",
maxConcurrent: 4,
});
localNode = await central.updateNode(localNode.id, { status: "online" });
}
nodes.push(localNode);
// 2. Register remote nodes (idempotently)
for (const sampleNode of SAMPLE_REMOTE_NODES) {
const existingByName = await central.getNodeByName(sampleNode.name);
if (existingByName) {
// Update existing node status
const updated = await central.updateNode(existingByName.id, { status: sampleNode.status });
nodes.push(updated);
console.log(` Updated existing node: ${sampleNode.name} (${sampleNode.status})`);
} else {
// Create new node
const remoteNode = await central.registerNode({
name: sampleNode.name,
type: "remote",
url: sampleNode.url,
maxConcurrent: sampleNode.maxConcurrent,
});
// Update status to the desired state
const updated = await central.updateNode(remoteNode.id, { status: sampleNode.status });
nodes.push(updated);
console.log(` Registered new node: ${sampleNode.name} (${sampleNode.status})`);
}
}
return nodes;
}
/**
* Seed the real central database and print results.
* Use this when running directly via tsx.
*/
async function main(): Promise<void> {
console.log("\n🌐 Seeding sample nodes into central database...\n");
const central = new CentralCore();
await central.init();
try {
const nodes = await seedSampleNodes(central);
console.log("\n📊 Registered nodes:\n");
console.log("┌─────────────────────────────────────────┬────────┬──────────────────────────────┬─────────┐");
console.log("│ Name │ Type │ URL │ Status │");
console.log("├─────────────────────────────────────────┼────────┼──────────────────────────────┼─────────┤");
for (const node of nodes) {
const type = node.type.padEnd(6);
const name = node.name.slice(0, 39).padEnd(39);
const url = (node.url ?? "-").slice(0, 28).padEnd(28);
const status = node.status.padEnd(7);
console.log(`${name}${type}${url}${status}`);
}
console.log("└─────────────────────────────────────────┴────────┴──────────────────────────────┴─────────┘");
// Summary stats
const total = nodes.length;
const localCount = nodes.filter((n) => n.type === "local").length;
const remoteCount = nodes.filter((n) => n.type === "remote").length;
const onlineCount = nodes.filter((n) => n.status === "online").length;
const offlineCount = nodes.filter((n) => n.status === "offline").length;
const errorCount = nodes.filter((n) => n.status === "error").length;
const connectingCount = nodes.filter((n) => n.status === "connecting").length;
console.log("\n📈 Summary:");
console.log(` Total nodes: ${total}`);
console.log(` Local: ${localCount}, Remote: ${remoteCount}`);
console.log(` Online: ${onlineCount}, Offline: ${offlineCount}, Error: ${errorCount}, Connecting: ${connectingCount}`);
console.log(`\n✅ Database: ${central.getDatabasePath()}\n`);
} finally {
await central.close();
}
}
// Run if executed directly
const isMainModule = process.argv[1]?.endsWith("seed-sample-nodes.ts");
if (isMainModule) {
main().catch((err) => {
console.error("\n❌ Seeding failed:", err);
process.exit(1);
});
}