feat(FN-1838): merge fusion/fn-1838
This commit is contained in:
31
demo/multi-node-dashboard.png
Normal file
31
demo/multi-node-dashboard.png
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Multi-Node Dashboard Screenshot
|
||||||
|
|
||||||
|
This file is a placeholder for the multi-node dashboard screenshot.
|
||||||
|
|
||||||
|
## How to Capture the Screenshot
|
||||||
|
|
||||||
|
1. **Run the seed script** to populate the central database with sample nodes:
|
||||||
|
```bash
|
||||||
|
npx tsx packages/core/src/__tests__/seed-sample-nodes.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Start the dashboard**:
|
||||||
|
```bash
|
||||||
|
pnpm build && node packages/cli/dist/bin.js dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Navigate to the Nodes view** in the dashboard
|
||||||
|
|
||||||
|
4. **Take a screenshot** and save it to this file location
|
||||||
|
|
||||||
|
## Expected View
|
||||||
|
|
||||||
|
When seeded correctly, the Nodes dashboard should show:
|
||||||
|
- **Total: 6 nodes** (1 local + 5 remote)
|
||||||
|
- **Online: 3 nodes** (local + Staging Server + Build Machine)
|
||||||
|
- **Offline: 2 nodes** (GPU Cluster + error status)
|
||||||
|
- **Remote: 5 nodes**
|
||||||
|
|
||||||
|
The mesh topology visualization should show:
|
||||||
|
- Central local node
|
||||||
|
- 5 remote nodes connected around it
|
||||||
559
packages/core/src/__tests__/multi-node-dashboard.test.ts
Normal file
559
packages/core/src/__tests__/multi-node-dashboard.test.ts
Normal 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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
170
packages/core/src/__tests__/seed-sample-nodes.ts
Normal file
170
packages/core/src/__tests__/seed-sample-nodes.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -188,4 +188,178 @@ describe("NodeCard", () => {
|
|||||||
// Remote node should show only 1 project (explicitly assigned only)
|
// Remote node should show only 1 project (explicitly assigned only)
|
||||||
expect(screen.getByText("1")).toBeDefined();
|
expect(screen.getByText("1")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("multi-node scenarios", () => {
|
||||||
|
it("renders remote node with long URL", () => {
|
||||||
|
const longUrl = "https://this-is-a-very-long-hostname.example.com/some/very/long/path/to/resource";
|
||||||
|
const node = makeNode({
|
||||||
|
id: "node-long-url",
|
||||||
|
name: "Long URL Node",
|
||||||
|
type: "remote",
|
||||||
|
url: longUrl,
|
||||||
|
status: "online",
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<NodeCard
|
||||||
|
node={node}
|
||||||
|
projects={[]}
|
||||||
|
onHealthCheck={vi.fn()}
|
||||||
|
onEdit={vi.fn()}
|
||||||
|
onRemove={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Node name should be visible
|
||||||
|
expect(screen.getByText("Long URL Node")).toBeDefined();
|
||||||
|
|
||||||
|
// URL should be visible (component may truncate it)
|
||||||
|
expect(screen.getByText(/this-is-a-very-long-hostname/)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders node with connecting status", () => {
|
||||||
|
const node = makeNode({
|
||||||
|
id: "node-connecting",
|
||||||
|
name: "Connecting Node",
|
||||||
|
type: "remote",
|
||||||
|
url: "https://connecting.example.com",
|
||||||
|
status: "connecting",
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<NodeCard
|
||||||
|
node={node}
|
||||||
|
projects={[]}
|
||||||
|
onHealthCheck={vi.fn()}
|
||||||
|
onEdit={vi.fn()}
|
||||||
|
onRemove={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Connecting Node")).toBeDefined();
|
||||||
|
expect(screen.getByText("Connecting")).toBeDefined();
|
||||||
|
expect(screen.getByText("Remote")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders node with error status", () => {
|
||||||
|
const node = makeNode({
|
||||||
|
id: "node-error",
|
||||||
|
name: "Error Node",
|
||||||
|
type: "remote",
|
||||||
|
url: "https://error.example.com",
|
||||||
|
status: "error",
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<NodeCard
|
||||||
|
node={node}
|
||||||
|
projects={[]}
|
||||||
|
onHealthCheck={vi.fn()}
|
||||||
|
onEdit={vi.fn()}
|
||||||
|
onRemove={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Error Node")).toBeDefined();
|
||||||
|
expect(screen.getByText("Error")).toBeDefined();
|
||||||
|
expect(screen.getByText("Remote")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remove button arms on first click, removes on second click", () => {
|
||||||
|
const onRemove = vi.fn();
|
||||||
|
const node = makeNode({ id: "node-remove-test", name: "Remove Test Node" });
|
||||||
|
|
||||||
|
render(
|
||||||
|
<NodeCard
|
||||||
|
node={node}
|
||||||
|
projects={[]}
|
||||||
|
onHealthCheck={vi.fn()}
|
||||||
|
onEdit={vi.fn()}
|
||||||
|
onRemove={onRemove}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// First click arms the button (shows confirm)
|
||||||
|
const removeButton = screen.getByLabelText("Remove node");
|
||||||
|
fireEvent.click(removeButton);
|
||||||
|
|
||||||
|
// Should show confirm text
|
||||||
|
expect(screen.getByText("Confirm")).toBeDefined();
|
||||||
|
expect(onRemove).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Second click removes
|
||||||
|
fireEvent.click(screen.getByLabelText("Confirm remove node"));
|
||||||
|
expect(onRemove).toHaveBeenCalledWith(node.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disarms remove on clicking the armed button again", () => {
|
||||||
|
const onRemove = vi.fn();
|
||||||
|
const node = makeNode({ id: "node-disarm", name: "Disarm Test Node" });
|
||||||
|
|
||||||
|
render(
|
||||||
|
<NodeCard
|
||||||
|
node={node}
|
||||||
|
projects={[]}
|
||||||
|
onHealthCheck={vi.fn()}
|
||||||
|
onEdit={vi.fn()}
|
||||||
|
onRemove={onRemove}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// First click arms the button
|
||||||
|
const removeButton = screen.getByLabelText("Remove node");
|
||||||
|
fireEvent.click(removeButton);
|
||||||
|
|
||||||
|
// Should show confirm text
|
||||||
|
expect(screen.getByText("Confirm")).toBeDefined();
|
||||||
|
|
||||||
|
// Click the armed button again to disarm (should not trigger remove)
|
||||||
|
const armedButton = screen.getByLabelText("Confirm remove node");
|
||||||
|
// Click the button again (third click) to disarm
|
||||||
|
fireEvent.click(armedButton);
|
||||||
|
|
||||||
|
// Should not call remove (it was disarmed, not confirmed)
|
||||||
|
// The button should now be disarmed back to "Remove" state
|
||||||
|
expect(screen.getByText("Remove")).toBeDefined();
|
||||||
|
expect(screen.queryByText("Confirm")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders sample seed nodes correctly", () => {
|
||||||
|
// Test the actual seed data nodes
|
||||||
|
const seedNodes = [
|
||||||
|
makeNode({ id: "node-staging-seed", name: "Staging Server X", type: "remote", url: "https://staging.runfusion.ai", status: "online", maxConcurrent: 4 }),
|
||||||
|
makeNode({ id: "node-gpu-seed", name: "GPU Cluster Y", type: "remote", url: "https://gpu.runfusion.ai", status: "offline", maxConcurrent: 16 }),
|
||||||
|
makeNode({ id: "node-dev-seed", name: "Dev Box Z", type: "remote", url: "http://192.168.1.100:4040", status: "error", maxConcurrent: 2 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const node of seedNodes) {
|
||||||
|
render(
|
||||||
|
<NodeCard
|
||||||
|
node={node}
|
||||||
|
projects={[]}
|
||||||
|
onHealthCheck={vi.fn()}
|
||||||
|
onEdit={vi.fn()}
|
||||||
|
onRemove={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify node is rendered with correct data
|
||||||
|
expect(screen.getByText(node.name, { exact: true })).toBeDefined();
|
||||||
|
|
||||||
|
// Verify correct type badge
|
||||||
|
const typeBadge = document.querySelector(".node-card__type-badge");
|
||||||
|
expect(typeBadge?.textContent).toBe("Remote");
|
||||||
|
|
||||||
|
// Verify correct status
|
||||||
|
const statusClass = `.node-card__status--${node.status}`;
|
||||||
|
const statusElement = document.querySelector(statusClass);
|
||||||
|
expect(statusElement).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Clear between renders
|
||||||
|
if (node !== seedNodes[seedNodes.length - 1]) {
|
||||||
|
render(null as unknown as JSX.Element);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -190,4 +190,83 @@ describe("NodesView", () => {
|
|||||||
fireEvent.click(closeButton);
|
fireEvent.click(closeButton);
|
||||||
expect(onClose).toHaveBeenCalledTimes(1);
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("multi-node dashboard scenarios", () => {
|
||||||
|
it("renders 6 sample nodes with correct stats and mesh topology", () => {
|
||||||
|
// Mock 6 nodes matching the seed data: 1 local + 5 remote
|
||||||
|
const sampleNodes = [
|
||||||
|
makeNode({ id: "node-local", name: "local", type: "local", status: "online", maxConcurrent: 4 }),
|
||||||
|
makeNode({ id: "node-staging", name: "Staging Server", type: "remote", url: "https://staging.runfusion.ai", status: "online", maxConcurrent: 4 }),
|
||||||
|
makeNode({ id: "node-build", name: "Build Machine", type: "remote", url: "https://build.runfusion.ai", status: "online", maxConcurrent: 8 }),
|
||||||
|
makeNode({ id: "node-gpu", name: "GPU Cluster", type: "remote", url: "https://gpu.runfusion.ai", status: "offline", maxConcurrent: 16 }),
|
||||||
|
makeNode({ id: "node-dev", name: "Dev Box (John)", type: "remote", url: "http://192.168.1.100:4040", status: "error", maxConcurrent: 2 }),
|
||||||
|
makeNode({ id: "node-qa", name: "QA Environment", type: "remote", url: "https://qa.runfusion.ai", status: "connecting", maxConcurrent: 4 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
mockUseNodes.mockReturnValue(makeUseNodesResult({ nodes: sampleNodes }));
|
||||||
|
|
||||||
|
render(<NodesView addToast={vi.fn()} onClose={vi.fn()} />);
|
||||||
|
|
||||||
|
// Check stats bar shows correct counts
|
||||||
|
expect(screen.getByTestId("nodes-stat-total").textContent).toContain("6");
|
||||||
|
expect(screen.getByTestId("nodes-stat-online").textContent).toContain("3"); // local + 2 remote
|
||||||
|
expect(screen.getByTestId("nodes-stat-offline").textContent).toContain("2"); // error + offline
|
||||||
|
expect(screen.getByTestId("nodes-stat-remote").textContent).toContain("5");
|
||||||
|
|
||||||
|
// Check 6 node cards are rendered
|
||||||
|
const nodeCards = document.querySelectorAll(".node-card");
|
||||||
|
expect(nodeCards).toHaveLength(6);
|
||||||
|
|
||||||
|
// Check mesh topology is visible
|
||||||
|
const svg = document.querySelector(".mesh-topology__svg");
|
||||||
|
expect(svg).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check header shows correct count
|
||||||
|
expect(screen.getByText("6 registered")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders all node names and statuses correctly", () => {
|
||||||
|
const sampleNodes = [
|
||||||
|
makeNode({ id: "node-alpha-xyz", name: "Alpha Node Xyz", status: "online", type: "local" }),
|
||||||
|
makeNode({ id: "node-beta-uvw", name: "Beta Node Uvw", status: "offline", type: "remote", url: "https://beta.node" }),
|
||||||
|
makeNode({ id: "node-gamma-rst", name: "Gamma Node Rst", status: "error", type: "remote", url: "https://gamma.node" }),
|
||||||
|
makeNode({ id: "node-delta-opq", name: "Delta Node Opq", status: "connecting", type: "remote", url: "https://delta.node" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
mockUseNodes.mockReturnValue(makeUseNodesResult({ nodes: sampleNodes }));
|
||||||
|
|
||||||
|
render(<NodesView addToast={vi.fn()} onClose={vi.fn()} />);
|
||||||
|
|
||||||
|
// Verify all node names are displayed (unique names to avoid collisions)
|
||||||
|
expect(screen.getByText("Alpha Node Xyz", { exact: true })).toBeDefined();
|
||||||
|
expect(screen.getByText("Beta Node Uvw", { exact: true })).toBeDefined();
|
||||||
|
expect(screen.getByText("Gamma Node Rst", { exact: true })).toBeDefined();
|
||||||
|
expect(screen.getByText("Delta Node Opq", { exact: true })).toBeDefined();
|
||||||
|
|
||||||
|
// Verify statuses are displayed (check existence)
|
||||||
|
const onlineElements = document.querySelectorAll(".node-card__status--online");
|
||||||
|
const offlineElements = document.querySelectorAll(".node-card__status--offline");
|
||||||
|
const errorElements = document.querySelectorAll(".node-card__status--error");
|
||||||
|
const connectingElements = document.querySelectorAll(".node-card__status--connecting");
|
||||||
|
|
||||||
|
expect(onlineElements.length).toBe(1);
|
||||||
|
expect(offlineElements.length).toBe(1);
|
||||||
|
expect(errorElements.length).toBe(1);
|
||||||
|
expect(connectingElements.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows empty state mesh topology indicator when only local node exists", () => {
|
||||||
|
mockUseNodes.mockReturnValue(makeUseNodesResult({
|
||||||
|
nodes: [makeNode({ id: "node-local", name: "local", type: "local", status: "online" })],
|
||||||
|
}));
|
||||||
|
|
||||||
|
render(<NodesView addToast={vi.fn()} onClose={vi.fn()} />);
|
||||||
|
|
||||||
|
// Stats should show only local
|
||||||
|
expect(screen.getByTestId("nodes-stat-total").textContent).toContain("1");
|
||||||
|
expect(screen.getByTestId("nodes-stat-online").textContent).toContain("1");
|
||||||
|
expect(screen.getByTestId("nodes-stat-offline").textContent).toContain("0");
|
||||||
|
expect(screen.getByTestId("nodes-stat-remote").textContent).toContain("0");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user