Mostly mechanical cleanup left over from the earlier test-consolidation pass: - Update import paths to ../../ for mocks now that test files moved deeper - Simplify mock setup (drop usePluginUiSlots inline mock, etc.) - Move engine ipc + runtimes tests into __tests__/ subdirs - Move dashboard utils tests into __tests__/ subdir - Refresh fusion-plugin-hermes-runtime/dist artifacts build-exe.test.ts: spawn-import fix from a parallel branch (resolved during worktree merge of the CSS extraction work). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
335 lines
10 KiB
TypeScript
335 lines
10 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { RuntimeMetrics } from "../../project-runtime.js";
|
|
import { RemoteNodeClient } from "../remote-node-client.js";
|
|
|
|
const BASE_URL = "https://node.example.com";
|
|
const API_KEY = "secret-token";
|
|
|
|
describe("RemoteNodeClient", () => {
|
|
const originalFetch = globalThis.fetch;
|
|
|
|
beforeEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
vi.restoreAllMocks();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("health() parses successful response and sends auth header", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ status: "ok", version: "1.0.0", uptime: 123 }), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
})
|
|
);
|
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
const client = new RemoteNodeClient({ baseUrl: BASE_URL, apiKey: API_KEY });
|
|
const health = await client.health();
|
|
|
|
expect(health).toEqual({ status: "ok", version: "1.0.0", uptime: 123 });
|
|
expect(fetchMock).toHaveBeenCalledWith(`${BASE_URL}/api/health`, expect.objectContaining({
|
|
method: "GET",
|
|
headers: expect.objectContaining({
|
|
Authorization: `Bearer ${API_KEY}`,
|
|
}),
|
|
}));
|
|
});
|
|
|
|
it("getMetrics() parses runtime metrics", async () => {
|
|
const metrics: RuntimeMetrics = {
|
|
inFlightTasks: 4,
|
|
activeAgents: 2,
|
|
lastActivityAt: "2026-04-08T00:00:00.000Z",
|
|
};
|
|
|
|
globalThis.fetch = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify(metrics), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
})
|
|
) as unknown as typeof fetch;
|
|
|
|
const client = new RemoteNodeClient({ baseUrl: BASE_URL, apiKey: API_KEY });
|
|
await expect(client.getMetrics()).resolves.toEqual(metrics);
|
|
});
|
|
|
|
it("createTask() sends POST with JSON body", async () => {
|
|
const createdTask = {
|
|
id: "KB-001",
|
|
description: "Create me",
|
|
column: "todo",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
status: "pending",
|
|
log: [],
|
|
attachments: [],
|
|
createdAt: "2026-04-08T00:00:00.000Z",
|
|
updatedAt: "2026-04-08T00:00:00.000Z",
|
|
size: "M",
|
|
reviewLevel: 1,
|
|
};
|
|
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify(createdTask), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
})
|
|
);
|
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
const client = new RemoteNodeClient({ baseUrl: BASE_URL, apiKey: API_KEY });
|
|
await client.createTask({ description: "Create me" });
|
|
|
|
const options = fetchMock.mock.calls[0]?.[1] as RequestInit;
|
|
expect(fetchMock).toHaveBeenCalledWith(`${BASE_URL}/api/tasks`, expect.any(Object));
|
|
expect(options.method).toBe("POST");
|
|
expect(options.headers).toEqual(expect.objectContaining({
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${API_KEY}`,
|
|
}));
|
|
expect(options.body).toBe(JSON.stringify({ description: "Create me" }));
|
|
});
|
|
|
|
it("listTasks() sends optional query params", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify([]), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
})
|
|
);
|
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
const client = new RemoteNodeClient({ baseUrl: BASE_URL, apiKey: API_KEY });
|
|
await client.listTasks({ column: "in-progress", limit: 10 });
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
`${BASE_URL}/api/tasks?column=in-progress&limit=10`,
|
|
expect.objectContaining({ method: "GET" })
|
|
);
|
|
});
|
|
|
|
it("executeTask() posts to execute endpoint", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ acknowledged: true }), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
})
|
|
);
|
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
const client = new RemoteNodeClient({ baseUrl: BASE_URL, apiKey: API_KEY });
|
|
const result = await client.executeTask("KB-123");
|
|
|
|
expect(result).toEqual({ acknowledged: true });
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
`${BASE_URL}/api/tasks/KB-123/execute`,
|
|
expect.objectContaining({ method: "POST" })
|
|
);
|
|
});
|
|
|
|
it("streamEvents() yields parsed events from SSE stream", async () => {
|
|
const sseBody = [
|
|
"event: task:created",
|
|
'data: {"type":"task:created","payload":{"id":"KB-1"},"timestamp":"2026-04-08T00:00:00.000Z"}',
|
|
"",
|
|
"event: task:updated",
|
|
'data: {"type":"task:updated","payload":{"id":"KB-1","column":"in-progress"},"timestamp":"2026-04-08T00:01:00.000Z"}',
|
|
"",
|
|
].join("\n");
|
|
|
|
globalThis.fetch = vi.fn().mockResolvedValue(
|
|
new Response(sseBody, {
|
|
status: 200,
|
|
headers: { "content-type": "text/event-stream" },
|
|
})
|
|
) as unknown as typeof fetch;
|
|
|
|
const client = new RemoteNodeClient({ baseUrl: BASE_URL, apiKey: API_KEY });
|
|
|
|
const events: unknown[] = [];
|
|
for await (const event of client.streamEvents()) {
|
|
events.push(event);
|
|
}
|
|
|
|
expect(events).toEqual([
|
|
{
|
|
type: "task:created",
|
|
payload: { id: "KB-1" },
|
|
timestamp: "2026-04-08T00:00:00.000Z",
|
|
},
|
|
{
|
|
type: "task:updated",
|
|
payload: { id: "KB-1", column: "in-progress" },
|
|
timestamp: "2026-04-08T00:01:00.000Z",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("retries on network errors", async () => {
|
|
vi.useFakeTimers();
|
|
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockRejectedValueOnce(new TypeError("network down"))
|
|
.mockResolvedValueOnce(
|
|
new Response(JSON.stringify({ status: "ok", version: "1.0.0", uptime: 123 }), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
})
|
|
);
|
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
const client = new RemoteNodeClient({ baseUrl: BASE_URL, apiKey: API_KEY });
|
|
|
|
const request = client.health();
|
|
const expectation = expect(request).resolves.toEqual({
|
|
status: "ok",
|
|
version: "1.0.0",
|
|
uptime: 123,
|
|
});
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
await expectation;
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("does not retry on 4xx responses", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ error: "unauthorized" }), {
|
|
status: 401,
|
|
statusText: "Unauthorized",
|
|
headers: { "content-type": "application/json" },
|
|
})
|
|
);
|
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
const client = new RemoteNodeClient({ baseUrl: BASE_URL, apiKey: API_KEY });
|
|
|
|
await expect(client.health()).rejects.toThrow("401 Unauthorized");
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("retries on 5xx responses", async () => {
|
|
vi.useFakeTimers();
|
|
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(
|
|
new Response("server error", { status: 500, statusText: "Internal Server Error" })
|
|
)
|
|
.mockResolvedValueOnce(
|
|
new Response("server error", { status: 502, statusText: "Bad Gateway" })
|
|
)
|
|
.mockResolvedValueOnce(
|
|
new Response(JSON.stringify({ status: "ok", version: "1.0.0", uptime: 999 }), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
})
|
|
);
|
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
const client = new RemoteNodeClient({ baseUrl: BASE_URL, apiKey: API_KEY });
|
|
|
|
const request = client.health();
|
|
const expectation = expect(request).resolves.toEqual({
|
|
status: "ok",
|
|
version: "1.0.0",
|
|
uptime: 999,
|
|
});
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
await vi.advanceTimersByTimeAsync(2000);
|
|
await expectation;
|
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it("aborts requests after timeoutMs", async () => {
|
|
vi.useFakeTimers();
|
|
|
|
const fetchMock = vi.fn().mockImplementation((_: unknown, init?: RequestInit) => {
|
|
return new Promise((_resolve, reject) => {
|
|
const signal = init?.signal;
|
|
signal?.addEventListener("abort", () => {
|
|
const abortError = new Error("aborted");
|
|
abortError.name = "AbortError";
|
|
reject(abortError);
|
|
});
|
|
});
|
|
});
|
|
|
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
const client = new RemoteNodeClient({
|
|
baseUrl: BASE_URL,
|
|
apiKey: API_KEY,
|
|
timeoutMs: 5,
|
|
});
|
|
|
|
const request = client.health();
|
|
const expectation = expect(request).rejects.toThrow("timed out");
|
|
|
|
await vi.runAllTimersAsync();
|
|
await expectation;
|
|
expect(fetchMock).toHaveBeenCalledTimes(4); // initial + 3 retries
|
|
});
|
|
|
|
it("sends auth header on all request methods", async () => {
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(
|
|
new Response(JSON.stringify({ status: "ok", version: "1.0.0", uptime: 1 }), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
})
|
|
)
|
|
.mockResolvedValueOnce(
|
|
new Response(JSON.stringify({ inFlightTasks: 0, activeAgents: 0, lastActivityAt: "now" }), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
})
|
|
)
|
|
.mockResolvedValueOnce(
|
|
new Response(JSON.stringify([]), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
})
|
|
)
|
|
.mockResolvedValueOnce(
|
|
new Response(JSON.stringify({ acknowledged: true }), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
})
|
|
)
|
|
.mockResolvedValueOnce(
|
|
new Response("event: ping\ndata: {}\n\n", {
|
|
status: 200,
|
|
headers: { "content-type": "text/event-stream" },
|
|
})
|
|
);
|
|
|
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
const client = new RemoteNodeClient({ baseUrl: BASE_URL, apiKey: API_KEY });
|
|
|
|
await client.health();
|
|
await client.getMetrics();
|
|
await client.listTasks();
|
|
await client.executeTask("KB-777");
|
|
for await (const _event of client.streamEvents()) {
|
|
// Drain one-response event stream
|
|
}
|
|
|
|
for (const call of fetchMock.mock.calls) {
|
|
const options = call[1] as RequestInit;
|
|
expect(options.headers).toEqual(
|
|
expect.objectContaining({
|
|
Authorization: `Bearer ${API_KEY}`,
|
|
})
|
|
);
|
|
}
|
|
});
|
|
});
|