Archived
feat: add machine federation
This commit is contained in:
+92
-2
@@ -1,11 +1,13 @@
|
||||
import { mkdtemp, realpath, rm, truncate, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { Readable } from "node:stream";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildApp } from "./app.js";
|
||||
import { ProjectService } from "./projects/projectService.js";
|
||||
import { ProjectStore } from "./storage/projectStore.js";
|
||||
import { RemoteMachineRequestError, type MachineClient } from "./machines/machineClient.js";
|
||||
import { MachineService } from "./machines/machineService.js";
|
||||
import { MachineStore } from "./machines/machineStore.js";
|
||||
import { WorkspaceService } from "./workspaces/workspaceService.js";
|
||||
@@ -15,14 +17,33 @@ import type { Project, Workspace } from "./types.js";
|
||||
let app: FastifyInstance;
|
||||
let tempDir: string;
|
||||
let projectDir: string;
|
||||
let remoteClient: MachineClient | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await realpath(await mkdtemp(join(tmpdir(), "pi-web-app-test-")));
|
||||
projectDir = join(tempDir, "project");
|
||||
remoteClient = undefined;
|
||||
app = await buildApp({
|
||||
projects: new ProjectService(new ProjectStore(join(tempDir, "projects.json"))),
|
||||
workspaces: new WorkspaceService(),
|
||||
machines: new MachineService(new MachineStore(join(tempDir, "machines.json"))),
|
||||
machines: new MachineService(new MachineStore(join(tempDir, "machines.json")), {
|
||||
remoteClientFactory: () => {
|
||||
if (remoteClient === undefined) throw new Error("No remote machine client configured");
|
||||
return remoteClient;
|
||||
},
|
||||
now: () => new Date("2026-05-25T00:00:00.000Z"),
|
||||
localStatus: () => Promise.resolve({
|
||||
packageName: "@jmfederico/pi-web",
|
||||
generatedAt: "2026-05-25T00:00:00.000Z",
|
||||
components: {
|
||||
web: { component: "web", label: "PI WEB", stale: false, available: true },
|
||||
sessiond: { component: "sessiond", label: "PI WEB Session Daemon", stale: false, available: true },
|
||||
},
|
||||
release: { packageName: "@jmfederico/pi-web", updateAvailable: false },
|
||||
commands: { update: "", restart: "", restartSystemd: "", restartDev: "" },
|
||||
messages: [],
|
||||
}),
|
||||
}),
|
||||
piWebPlugins: {
|
||||
manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local" }] }),
|
||||
readAsset: (pluginId, assetPath) => Promise.resolve(pluginId === "fake" && assetPath === "plugin.js" ? { content: Buffer.from("export default {};"), contentType: "application/javascript; charset=utf-8" } : undefined),
|
||||
@@ -53,6 +74,66 @@ describe("buildApp", () => {
|
||||
expect(addResponse.json()).not.toHaveProperty("token");
|
||||
});
|
||||
|
||||
it("reports machine health for local and remote machines", async () => {
|
||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
const requestJson: MachineClient["requestJson"] = () => Promise.resolve({
|
||||
statusCode: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: {
|
||||
packageName: "@jmfederico/pi-web",
|
||||
generatedAt: "2026-05-25T00:00:00.000Z",
|
||||
components: {
|
||||
web: { component: "web", label: "Remote Web", stale: false, available: true },
|
||||
sessiond: { component: "sessiond", label: "Remote Sessiond", stale: false, available: true },
|
||||
},
|
||||
release: { packageName: "@jmfederico/pi-web", updateAvailable: false },
|
||||
commands: { update: "", restart: "", restartSystemd: "", restartDev: "" },
|
||||
messages: [],
|
||||
},
|
||||
});
|
||||
remoteClient = fakeRemoteClient({ requestJson });
|
||||
|
||||
const localHealth = await app.inject({ method: "GET", url: "/api/machines/local/health" });
|
||||
const remoteHealth = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/health` });
|
||||
|
||||
expect(localHealth.statusCode).toBe(200);
|
||||
expect(localHealth.json()).toMatchObject({ machineId: "local", ok: true, status: "online" });
|
||||
expect(remoteHealth.statusCode).toBe(200);
|
||||
expect(remoteHealth.json()).toMatchObject({ machineId: remote.id, ok: true, status: "online" });
|
||||
});
|
||||
|
||||
it("proxies allowlisted remote HTTP routes through the selected machine", async () => {
|
||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
const request = vi.fn(() => Promise.resolve({
|
||||
statusCode: 200,
|
||||
headers: { "content-type": "application/json", connection: "close" },
|
||||
body: Readable.from([JSON.stringify([{ id: "p1", name: "Remote Project", path: "/repo", createdAt: "now" }])]),
|
||||
}));
|
||||
remoteClient = fakeRemoteClient({ request });
|
||||
|
||||
const response = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/projects?active=true` });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers["content-type"]).toContain("application/json");
|
||||
expect(response.json()).toEqual([{ id: "p1", name: "Remote Project", path: "/repo", createdAt: "now" }]);
|
||||
expect(request).toHaveBeenCalledWith("GET", "/api/projects?active=true", undefined);
|
||||
});
|
||||
|
||||
it("forwards remote JSON request bodies and normalizes remote timeouts", async () => {
|
||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
const request = vi.fn(() => Promise.reject(new RemoteMachineRequestError("timed out", 504)));
|
||||
remoteClient = fakeRemoteClient({ request });
|
||||
|
||||
const response = await app.inject({ method: "POST", url: `/api/machines/${remote.id}/sessions/s1/prompt`, payload: { text: "hello" } });
|
||||
|
||||
expect(response.statusCode).toBe(504);
|
||||
expect(response.json()).toMatchObject({ error: "Remote machine timeout", machineId: remote.id, statusCode: 504 });
|
||||
expect(request).toHaveBeenCalledWith("POST", "/api/sessions/s1/prompt", { text: "hello" });
|
||||
});
|
||||
|
||||
it("adds, lists, and closes projects through the HTTP contract", async () => {
|
||||
const addResponse = await app.inject({
|
||||
method: "POST",
|
||||
@@ -189,3 +270,12 @@ describe("buildApp", () => {
|
||||
expect(tooLargeResponse.json()).toEqual({ error: "Image is too large to preview (limit 10 MB)" });
|
||||
});
|
||||
});
|
||||
|
||||
function fakeRemoteClient(overrides: Partial<MachineClient>): MachineClient {
|
||||
return {
|
||||
request: () => Promise.resolve({ statusCode: 200, headers: {}, body: Readable.from([]) }),
|
||||
requestJson: () => Promise.resolve({ statusCode: 200, headers: {}, body: undefined }),
|
||||
connectWebSocket: () => { throw new Error("WebSocket not configured for test"); },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user