merge main and fix CI: add plugin build step to pr-checks workflow
Merges origin/main to bring in the droid runtime plugin (needed by plugin-loader test) and all recent changes. Adds a "Build plugins" step to pr-checks.yml so plugin dist/ directories are compiled before tests run — fixes ERR_MODULE_NOT_FOUND for hermes and droid plugins. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -155,7 +155,7 @@ describe("Database", () => {
|
||||
});
|
||||
|
||||
it("seeds schema version", () => {
|
||||
expect(db.getSchemaVersion()).toBe(59);
|
||||
expect(db.getSchemaVersion()).toBe(60);
|
||||
});
|
||||
|
||||
it("seeds lastModified", () => {
|
||||
@@ -178,7 +178,7 @@ describe("Database", () => {
|
||||
|
||||
it("is idempotent - calling init() twice does not fail", () => {
|
||||
expect(() => db.init()).not.toThrow();
|
||||
expect(db.getSchemaVersion()).toBe(59);
|
||||
expect(db.getSchemaVersion()).toBe(60);
|
||||
});
|
||||
|
||||
it("does not overwrite existing config on re-init", () => {
|
||||
@@ -952,7 +952,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
|
||||
expect(db.getSchemaVersion()).toBe(59);
|
||||
expect(db.getSchemaVersion()).toBe(60);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -977,11 +977,11 @@ describe("schema migrations", () => {
|
||||
const db = new Database(fusionDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(59);
|
||||
expect(db.getSchemaVersion()).toBe(60);
|
||||
|
||||
// Re-init should not fail
|
||||
db.init();
|
||||
expect(db.getSchemaVersion()).toBe(59);
|
||||
expect(db.getSchemaVersion()).toBe(60);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -1016,7 +1016,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(59);
|
||||
expect(db.getSchemaVersion()).toBe(60);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
expect(cols.map((col) => col.name)).toContain("priority");
|
||||
@@ -1057,7 +1057,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(59);
|
||||
expect(db.getSchemaVersion()).toBe(60);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
const colNames = cols.map((col) => col.name);
|
||||
@@ -1126,7 +1126,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(59);
|
||||
expect(db.getSchemaVersion()).toBe(60);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
const colNames = cols.map((col) => col.name);
|
||||
@@ -1229,7 +1229,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(59);
|
||||
expect(db.getSchemaVersion()).toBe(60);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>;
|
||||
expect(cols.map((col) => col.name)).toContain("attachments");
|
||||
@@ -1303,7 +1303,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(59);
|
||||
expect(db.getSchemaVersion()).toBe(60);
|
||||
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
|
||||
expect(tables).toEqual([{ name: "agentRatings" }]);
|
||||
@@ -1327,7 +1327,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(59);
|
||||
expect(db.getSchemaVersion()).toBe(60);
|
||||
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
|
||||
expect(tables).toEqual([{ name: "mission_events" }]);
|
||||
@@ -1431,7 +1431,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 29
|
||||
expect(db.getSchemaVersion()).toBe(59);
|
||||
expect(db.getSchemaVersion()).toBe(60);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -1900,7 +1900,7 @@ describe("createDatabase factory", () => {
|
||||
const db = createDatabase(fusionDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(59);
|
||||
expect(db.getSchemaVersion()).toBe(60);
|
||||
expect(db.getLastModified()).toBeGreaterThan(0);
|
||||
|
||||
db.close();
|
||||
|
||||
134
packages/core/src/__tests__/docker-client.test.ts
Normal file
134
packages/core/src/__tests__/docker-client.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
const { execMock, readFileMock, pingMock, versionMock, inspectMock, dockerCtor } = vi.hoisted(() => {
|
||||
const execMock = vi.fn();
|
||||
const readFileMock = vi.fn();
|
||||
const pingMock = vi.fn();
|
||||
const versionMock = vi.fn();
|
||||
const inspectMock = vi.fn();
|
||||
const dockerCtor = vi.fn().mockImplementation(() => ({
|
||||
ping: pingMock,
|
||||
version: versionMock,
|
||||
getContainer: vi.fn(() => ({ inspect: inspectMock })),
|
||||
}));
|
||||
return { execMock, readFileMock, pingMock, versionMock, inspectMock, dockerCtor };
|
||||
});
|
||||
|
||||
vi.mock("dockerode", () => ({ default: dockerCtor }));
|
||||
vi.mock("node:child_process", () => ({ exec: execMock }));
|
||||
vi.mock("node:fs/promises", () => ({ readFile: readFileMock }));
|
||||
|
||||
import { DockerClientService } from "../docker-client";
|
||||
|
||||
describe("DockerClientService", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
execMock.mockImplementation((cmd: string, _opts: unknown, cb: (err: unknown, out: { stdout: string; stderr: string }) => void) => cb(null, { stdout: "", stderr: "" }));
|
||||
pingMock.mockResolvedValue(undefined);
|
||||
versionMock.mockResolvedValue({ Version: "24.0.0", ApiVersion: "1.43", Os: "linux" });
|
||||
readFileMock.mockResolvedValue(Buffer.from("x"));
|
||||
});
|
||||
|
||||
it.each([
|
||||
[undefined, undefined],
|
||||
[{ host: "tcp://1.2.3.4:2376" }, { host: "tcp://1.2.3.4:2376" }],
|
||||
])("creates docker instance for mode", async (hostConfig, expected) => {
|
||||
const service = new DockerClientService();
|
||||
await service.testConnection(hostConfig as never);
|
||||
if (expected) expect(dockerCtor).toHaveBeenCalledWith(expected);
|
||||
else expect(dockerCtor).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it("returns success connection result", async () => {
|
||||
const service = new DockerClientService();
|
||||
const result = await service.testConnection();
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.dockerVersion).toBe("24.0.0");
|
||||
expect(result.apiVersion).toBe("1.43");
|
||||
expect(result.operatingSystem).toBe("linux");
|
||||
expect(result.isLocalDaemon).toBe(true);
|
||||
});
|
||||
|
||||
it("marks remote host as non-local daemon", async () => {
|
||||
const service = new DockerClientService();
|
||||
const result = await service.testConnection({ host: "tcp://1.2.3.4:2376" });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.isLocalDaemon).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ mode: "context", hostConfig: { context: "my-remote" } },
|
||||
{
|
||||
mode: "host+tls",
|
||||
hostConfig: {
|
||||
host: "tcp://1.2.3.4:2376",
|
||||
tlsVerify: true,
|
||||
tlsCaPath: "/ca.pem",
|
||||
tlsCertPath: "/cert.pem",
|
||||
tlsKeyPath: "/key.pem",
|
||||
},
|
||||
},
|
||||
])("covers additional mode $mode", async ({ hostConfig }) => {
|
||||
if ((hostConfig as any).context) {
|
||||
execMock.mockImplementation((cmd: string, _opts: unknown, cb: (err: unknown, out: { stdout: string; stderr: string }) => void) => {
|
||||
if (cmd.includes("context inspect")) cb(null, { stdout: '[{"Endpoints":{"docker":{"Host":"tcp://ctx:2376"}}}]', stderr: "" });
|
||||
else cb(null, { stdout: "", stderr: "" });
|
||||
});
|
||||
}
|
||||
const service = new DockerClientService();
|
||||
await service.testConnection(hostConfig as any);
|
||||
expect(dockerCtor).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses docker context", async () => {
|
||||
execMock.mockImplementation((cmd: string, _opts: unknown, cb: (err: unknown, out: { stdout: string; stderr: string }) => void) => {
|
||||
if (cmd.includes("context inspect")) cb(null, { stdout: '[{"Endpoints":{"docker":{"Host":"tcp://ctx:2376"}}}]', stderr: "" });
|
||||
else cb(null, { stdout: "", stderr: "" });
|
||||
});
|
||||
const service = new DockerClientService();
|
||||
await service.testConnection({ context: "my-remote" });
|
||||
expect(execMock.mock.calls[0][0]).toContain("docker context inspect");
|
||||
expect(dockerCtor).toHaveBeenCalledWith({ host: "tcp://ctx:2376" });
|
||||
});
|
||||
|
||||
it("supports host with tls", async () => {
|
||||
const service = new DockerClientService();
|
||||
await service.testConnection({ host: "tcp://1.2.3.4:2376", tlsVerify: true, tlsCaPath: "/ca.pem", tlsCertPath: "/cert.pem", tlsKeyPath: "/key.pem" });
|
||||
expect(readFileMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("returns failure when ping fails", async () => {
|
||||
pingMock.mockRejectedValue(new Error("connect ECONNREFUSED"));
|
||||
const service = new DockerClientService();
|
||||
const result = await service.testConnection();
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("ECONNREFUSED");
|
||||
});
|
||||
|
||||
it("lists contexts and ENOENT fallback", async () => {
|
||||
execMock.mockImplementationOnce((cmd: string, _opts: unknown, cb: (err: unknown, out: { stdout: string; stderr: string }) => void) => cb(null, { stdout: '{"Name":"default","Current":true}\n{"Name":"remote","DockerHost":"tcp://1.2.3.4:2376","Current":false}\n', stderr: "" }));
|
||||
const service = new DockerClientService();
|
||||
const contexts = await service.listContexts();
|
||||
expect(contexts).toHaveLength(2);
|
||||
|
||||
execMock.mockImplementationOnce((_cmd: string, _opts: unknown, cb: (err: unknown) => void) => cb(new Error("ENOENT")));
|
||||
const fallback = await service.listContexts();
|
||||
expect(fallback[0].name).toBe("default");
|
||||
});
|
||||
|
||||
it("gets container info and not found", async () => {
|
||||
inspectMock.mockResolvedValue({ Id: "abc", Name: "/container", Created: "2020-01-01T00:00:00Z", Config: { Image: "img:latest" }, State: { Status: "running", Running: true, Paused: false, Restarting: false, Dead: false } });
|
||||
const service = new DockerClientService();
|
||||
const container = await service.getContainerInfo("abc");
|
||||
expect(container?.name).toBe("container");
|
||||
|
||||
inspectMock.mockRejectedValue(new Error("404 no such container"));
|
||||
const missing = await service.getContainerInfo("missing");
|
||||
expect(missing).toBeNull();
|
||||
});
|
||||
|
||||
it("does not use execSync", async () => {
|
||||
const source = await import("node:fs/promises").then((m) => m.readFile(new URL("../docker-client.ts", import.meta.url), "utf8"));
|
||||
expect(source.includes("execSync")).toBe(false);
|
||||
});
|
||||
});
|
||||
491
packages/core/src/__tests__/docker-provisioning.test.ts
Normal file
491
packages/core/src/__tests__/docker-provisioning.test.ts
Normal file
@@ -0,0 +1,491 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
const {
|
||||
getDockerInstanceMock,
|
||||
pullMock,
|
||||
followProgressMock,
|
||||
createContainerMock,
|
||||
startMock,
|
||||
stopMock,
|
||||
removeMock,
|
||||
restartMock,
|
||||
inspectMock,
|
||||
getImageInspectMock,
|
||||
getContainerMock,
|
||||
dockerCtor,
|
||||
} = vi.hoisted(() => {
|
||||
const pullMock = vi.fn();
|
||||
const followProgressMock = vi.fn();
|
||||
const createContainerMock = vi.fn();
|
||||
const startMock = vi.fn();
|
||||
const stopMock = vi.fn();
|
||||
const removeMock = vi.fn();
|
||||
const restartMock = vi.fn();
|
||||
const inspectMock = vi.fn();
|
||||
const getImageInspectMock = vi.fn();
|
||||
const getContainerMock = vi.fn();
|
||||
const dockerCtor = vi.fn();
|
||||
|
||||
getContainerMock.mockReturnValue({
|
||||
start: startMock,
|
||||
stop: stopMock,
|
||||
remove: removeMock,
|
||||
restart: restartMock,
|
||||
inspect: inspectMock,
|
||||
});
|
||||
|
||||
return {
|
||||
getDockerInstanceMock: vi.fn(),
|
||||
pullMock,
|
||||
followProgressMock,
|
||||
createContainerMock,
|
||||
startMock,
|
||||
stopMock,
|
||||
removeMock,
|
||||
restartMock,
|
||||
inspectMock,
|
||||
getImageInspectMock,
|
||||
getContainerMock,
|
||||
dockerCtor,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../docker-client.js", () => ({
|
||||
DockerClientService: vi.fn().mockImplementation(() => ({
|
||||
getDockerInstance: getDockerInstanceMock,
|
||||
getContainerInfo: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
import { DockerProvisioningService } from "../docker-provisioning";
|
||||
import { DockerClientService } from "../docker-client";
|
||||
import type { DockerProvisionInput } from "../types";
|
||||
|
||||
function createMockDocker() {
|
||||
return {
|
||||
pull: pullMock,
|
||||
modem: { followProgress: followProgressMock },
|
||||
createContainer: createContainerMock,
|
||||
getImage: vi.fn(() => ({ inspect: getImageInspectMock })),
|
||||
getContainer: getContainerMock,
|
||||
};
|
||||
}
|
||||
|
||||
function createBaseInput(overrides?: Partial<DockerProvisionInput>): DockerProvisionInput {
|
||||
return {
|
||||
nodeName: "test-node",
|
||||
hostConfig: {},
|
||||
imageConfig: {
|
||||
image: "runfusion/fusion",
|
||||
tag: "latest",
|
||||
pullImage: true,
|
||||
},
|
||||
autoGenerateApiKey: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("DockerProvisioningService", () => {
|
||||
let service: DockerProvisioningService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2025-01-01"));
|
||||
|
||||
const mockDocker = createMockDocker();
|
||||
getDockerInstanceMock.mockResolvedValue(mockDocker);
|
||||
pullMock.mockResolvedValue("stream");
|
||||
followProgressMock.mockImplementation((_stream: unknown, cb: (err: Error | null) => void) => cb(null));
|
||||
createContainerMock.mockResolvedValue({
|
||||
id: "container-abc123",
|
||||
start: startMock,
|
||||
inspect: inspectMock,
|
||||
remove: removeMock,
|
||||
});
|
||||
startMock.mockResolvedValue(undefined);
|
||||
inspectMock.mockResolvedValue({
|
||||
Id: "container-abc123",
|
||||
NetworkSettings: { Ports: { "4040/tcp": [{ HostPort: "49152" }] } },
|
||||
});
|
||||
removeMock.mockResolvedValue(undefined);
|
||||
stopMock.mockResolvedValue(undefined);
|
||||
restartMock.mockResolvedValue(undefined);
|
||||
getImageInspectMock.mockResolvedValue({});
|
||||
|
||||
const clientService = new DockerClientService();
|
||||
service = new DockerProvisioningService(clientService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("provision", () => {
|
||||
it("provisions a container successfully", async () => {
|
||||
const result = await service.provision(createBaseInput());
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.containerId).toBe("container-abc123");
|
||||
expect(result.containerName).toMatch(/^fusion-test-node-[a-f0-9]{8}$/);
|
||||
expect(result.apiKey).toMatch(/^fn_[a-f0-9]+$/);
|
||||
expect(result.portMapping).toBe("4040:49152");
|
||||
expect(result.durationMs).toBeGreaterThanOrEqual(0);
|
||||
|
||||
// Verify delegation to DockerClientService
|
||||
expect(getDockerInstanceMock).toHaveBeenCalledWith({});
|
||||
|
||||
// Verify image pull
|
||||
expect(pullMock).toHaveBeenCalledWith("runfusion/fusion:latest", undefined);
|
||||
|
||||
// Verify container creation
|
||||
expect(createContainerMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
Image: "runfusion/fusion:latest",
|
||||
ExposedPorts: { "4040/tcp": {} },
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify environment includes required vars
|
||||
const createCall = createContainerMock.mock.calls[0][0];
|
||||
expect(createCall.Env).toContain("FUSION_NODE_NAME=test-node");
|
||||
expect(createCall.Env).toContain("FUSION_MODE=serve");
|
||||
expect(createCall.Env).toContain("FUSION_PORT=4040");
|
||||
expect(createCall.Env).toContain("FUSION_DATA_DIR=/data");
|
||||
expect(createCall.Env.some((e: string) => e.startsWith("FUSION_API_KEY="))).toBe(true);
|
||||
|
||||
// Verify labels
|
||||
expect(createCall.Labels).toEqual(
|
||||
expect.objectContaining({
|
||||
"fusion.managed": "true",
|
||||
"fusion.node-name": "test-node",
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify HostConfig
|
||||
expect(createCall.HostConfig.RestartPolicy).toEqual({ Name: "unless-stopped" });
|
||||
expect(createCall.HostConfig.PortBindings).toEqual({ "4040/tcp": [{ HostPort: "0" }] });
|
||||
});
|
||||
|
||||
it("includes extra CLIs in environment", async () => {
|
||||
const result = await service.provision(
|
||||
createBaseInput({ extraClis: ["claude", "droid"] }),
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const createCall = createContainerMock.mock.calls[0][0];
|
||||
expect(createCall.Env).toContain("FUSION_EXTRA_CLIS=claude,droid");
|
||||
});
|
||||
|
||||
it("applies resource limits to HostConfig", async () => {
|
||||
await service.provision(
|
||||
createBaseInput({
|
||||
resourceConfig: {
|
||||
cpuLimit: 2,
|
||||
memoryLimitMb: 4096,
|
||||
memorySwapMb: 8192,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const hostConfig = createContainerMock.mock.calls[0][0].HostConfig;
|
||||
expect(hostConfig.Memory).toBe(4096 * 1024 * 1024);
|
||||
expect(hostConfig.NanoCpus).toBe(2_000_000_000);
|
||||
expect(hostConfig.MemorySwap).toBe(8192 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it("applies custom network config", async () => {
|
||||
await service.provision(
|
||||
createBaseInput({ network: "fusion-net" }),
|
||||
);
|
||||
|
||||
const createCall = createContainerMock.mock.calls[0][0];
|
||||
expect(createCall.NetworkingConfig).toEqual({
|
||||
EndpointsConfig: { "fusion-net": {} },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses persistent volume mount", async () => {
|
||||
await service.provision(
|
||||
createBaseInput({ persistentVolume: "fusion-data" }),
|
||||
);
|
||||
|
||||
const hostConfig = createContainerMock.mock.calls[0][0].HostConfig;
|
||||
expect(hostConfig.Binds).toContain("fusion-data:/data");
|
||||
});
|
||||
|
||||
it("appends user-provided volume mounts", async () => {
|
||||
await service.provision(
|
||||
createBaseInput({
|
||||
persistentVolume: "fusion-data",
|
||||
volumeMounts: ["/host/path:/container/path"],
|
||||
}),
|
||||
);
|
||||
|
||||
const hostConfig = createContainerMock.mock.calls[0][0].HostConfig;
|
||||
expect(hostConfig.Binds).toEqual(["fusion-data:/data", "/host/path:/container/path"]);
|
||||
});
|
||||
|
||||
it("appends user-provided environment variables", async () => {
|
||||
await service.provision(
|
||||
createBaseInput({ environment: ["CUSTOM_VAR=value"] }),
|
||||
);
|
||||
|
||||
const createCall = createContainerMock.mock.calls[0][0];
|
||||
expect(createCall.Env).toContain("CUSTOM_VAR=value");
|
||||
});
|
||||
|
||||
it("uses user-provided API key when autoGenerateApiKey is false", async () => {
|
||||
const result = await service.provision(
|
||||
createBaseInput({
|
||||
autoGenerateApiKey: false,
|
||||
apiKey: "my-custom-key",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.apiKey).toBe("my-custom-key");
|
||||
const createCall = createContainerMock.mock.calls[0][0];
|
||||
expect(createCall.Env).toContain("FUSION_API_KEY=my-custom-key");
|
||||
});
|
||||
|
||||
it("returns error on image pull failure", async () => {
|
||||
pullMock.mockRejectedValue(new Error("registry unavailable"));
|
||||
|
||||
const result = await service.provision(createBaseInput());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.failedStage).toBe("image-pull");
|
||||
expect(result.error).toContain("registry unavailable");
|
||||
});
|
||||
|
||||
it("returns error when local image not found and pullImage is false", async () => {
|
||||
getImageInspectMock.mockRejectedValue(new Error("no such image"));
|
||||
|
||||
const result = await service.provision(
|
||||
createBaseInput({ imageConfig: { image: "runfusion/fusion", tag: "latest", pullImage: false } }),
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.failedStage).toBe("image-pull");
|
||||
expect(result.error).toContain("not found locally");
|
||||
});
|
||||
|
||||
it("returns error on container create failure", async () => {
|
||||
createContainerMock.mockRejectedValue(new Error("name conflict"));
|
||||
|
||||
const result = await service.provision(createBaseInput());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.failedStage).toBe("container-create");
|
||||
expect(result.error).toContain("name conflict");
|
||||
});
|
||||
|
||||
it("returns error on container start failure and cleans up", async () => {
|
||||
startMock.mockRejectedValue(new Error("port already in use"));
|
||||
|
||||
const result = await service.provision(createBaseInput());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.failedStage).toBe("container-start");
|
||||
expect(result.error).toContain("port already in use");
|
||||
expect(removeMock).toHaveBeenCalledWith({ force: true });
|
||||
});
|
||||
|
||||
it("passes registry auth when credentials are provided", async () => {
|
||||
await service.provision(
|
||||
createBaseInput({
|
||||
imageConfig: {
|
||||
image: "ghcr.io/runfusion/fusion",
|
||||
tag: "v1",
|
||||
pullImage: true,
|
||||
registryUsername: "user",
|
||||
registryPassword: "pass",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(pullMock).toHaveBeenCalledWith(
|
||||
"ghcr.io/runfusion/fusion:v1",
|
||||
{ authconfig: { username: "user", password: "pass" } },
|
||||
);
|
||||
});
|
||||
|
||||
it("delegates getDockerInstance to DockerClientService", async () => {
|
||||
const hostConfig = { host: "tcp://1.2.3.4:2376" };
|
||||
await service.provision(createBaseInput({ hostConfig }));
|
||||
|
||||
expect(getDockerInstanceMock).toHaveBeenCalledWith(hostConfig);
|
||||
});
|
||||
|
||||
it("includes reachableUrl in environment when provided", async () => {
|
||||
await service.provision(
|
||||
createBaseInput({ reachableUrl: "http://my-node:4040" }),
|
||||
);
|
||||
|
||||
const createCall = createContainerMock.mock.calls[0][0];
|
||||
expect(createCall.Env).toContain("FUSION_REACHABLE_URL=http://my-node:4040");
|
||||
});
|
||||
|
||||
it("merges user-provided labels", async () => {
|
||||
await service.provision(
|
||||
createBaseInput({ labels: { env: "production" } }),
|
||||
);
|
||||
|
||||
const createCall = createContainerMock.mock.calls[0][0];
|
||||
expect(createCall.Labels).toEqual({
|
||||
"fusion.managed": "true",
|
||||
"fusion.node-name": "test-node",
|
||||
env: "production",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("deprovision", () => {
|
||||
it("stops and removes container", async () => {
|
||||
const result = await service.deprovision("container-123", {}, false);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(getContainerMock).toHaveBeenCalledWith("container-123");
|
||||
expect(stopMock).toHaveBeenCalledWith({ t: 10 });
|
||||
expect(removeMock).toHaveBeenCalledWith({ force: true, v: false });
|
||||
});
|
||||
|
||||
it("passes removeVolumes flag to remove", async () => {
|
||||
await service.deprovision("container-123", {}, true);
|
||||
|
||||
expect(removeMock).toHaveBeenCalledWith({ force: true, v: true });
|
||||
});
|
||||
|
||||
it("proceeds to remove when container is already stopped", async () => {
|
||||
stopMock.mockRejectedValue(new Error("is not running"));
|
||||
|
||||
const result = await service.deprovision("container-123", {}, false);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(removeMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("proceeds to remove on 'already stopped' error", async () => {
|
||||
stopMock.mockRejectedValue(new Error("already stopped"));
|
||||
|
||||
const result = await service.deprovision("container-123", {}, false);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(removeMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns error on remove failure", async () => {
|
||||
removeMock.mockRejectedValue(new Error("removal failed"));
|
||||
|
||||
const result = await service.deprovision("container-123", {}, false);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("removal failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("startContainer", () => {
|
||||
it("starts a container", async () => {
|
||||
const result = await service.startContainer("container-123", {});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(getContainerMock).toHaveBeenCalledWith("container-123");
|
||||
expect(startMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns error on start failure", async () => {
|
||||
startMock.mockRejectedValue(new Error("already running"));
|
||||
|
||||
const result = await service.startContainer("container-123", {});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("already running");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stopContainer", () => {
|
||||
it("stops a container", async () => {
|
||||
const result = await service.stopContainer("container-123", {});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(stopMock).toHaveBeenCalledWith({ t: 10 });
|
||||
});
|
||||
|
||||
it("returns error on stop failure", async () => {
|
||||
stopMock.mockRejectedValue(new Error("not running"));
|
||||
|
||||
const result = await service.stopContainer("container-123", {});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("not running");
|
||||
});
|
||||
});
|
||||
|
||||
describe("restartContainer", () => {
|
||||
it("restarts a container", async () => {
|
||||
const result = await service.restartContainer("container-123", {});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(restartMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns error on restart failure", async () => {
|
||||
restartMock.mockRejectedValue(new Error("timeout"));
|
||||
|
||||
const result = await service.restartContainer("container-123", {});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("timeout");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getContainerStatus", () => {
|
||||
it("returns container status via DockerClientService", async () => {
|
||||
const mockDocker = createMockDocker();
|
||||
mockDocker.getContainer = vi.fn().mockReturnValue({
|
||||
inspect: vi.fn().mockResolvedValue({
|
||||
Id: "container-abc",
|
||||
Name: "/fusion-test",
|
||||
State: { Status: "running", Running: true, Paused: false, Restarting: false, Dead: false },
|
||||
Config: { Image: "runfusion/fusion:latest" },
|
||||
Created: "2025-01-01T00:00:00Z",
|
||||
}),
|
||||
});
|
||||
getDockerInstanceMock.mockResolvedValue(mockDocker);
|
||||
|
||||
const result = await service.getContainerStatus("container-abc", {});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.id).toBe("container-abc");
|
||||
expect(result!.name).toBe("fusion-test");
|
||||
expect(result!.status).toBe("running");
|
||||
expect(result!.state.running).toBe(true);
|
||||
});
|
||||
|
||||
it("returns null for non-existent container", async () => {
|
||||
const mockDocker = createMockDocker();
|
||||
mockDocker.getContainer = vi.fn().mockReturnValue({
|
||||
inspect: vi.fn().mockRejectedValue(new Error("404 no such container")),
|
||||
});
|
||||
getDockerInstanceMock.mockResolvedValue(mockDocker);
|
||||
|
||||
const result = await service.getContainerStatus("nonexistent", {});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("delegates to getDockerInstance with hostConfig", async () => {
|
||||
const hostConfig = { host: "tcp://1.2.3.4:2376" };
|
||||
const mockDocker = createMockDocker();
|
||||
mockDocker.getContainer = vi.fn().mockReturnValue({
|
||||
inspect: vi.fn().mockRejectedValue(new Error("404")),
|
||||
});
|
||||
getDockerInstanceMock.mockResolvedValue(mockDocker);
|
||||
|
||||
await service.getContainerStatus("abc", hostConfig);
|
||||
|
||||
expect(getDockerInstanceMock).toHaveBeenCalledWith(hostConfig);
|
||||
});
|
||||
});
|
||||
});
|
||||
185
packages/core/src/__tests__/insight-run-executor.test.ts
Normal file
185
packages/core/src/__tests__/insight-run-executor.test.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createDatabase } from "../db.js";
|
||||
import { InsightLifecycleError, InsightStore } from "../insight-store.js";
|
||||
import { classifyInsightRunError, executeInsightRunLifecycle, retryInsightRunLifecycle } from "../insight-run-executor.js";
|
||||
|
||||
function createStore(): InsightStore {
|
||||
const fusionDir = mkdtempSync(join(tmpdir(), "fn-insight-executor-"));
|
||||
const db = createDatabase(fusionDir, { inMemory: true });
|
||||
db.init();
|
||||
return new InsightStore(db);
|
||||
}
|
||||
|
||||
describe("classifyInsightRunError", () => {
|
||||
it("classifies cancellation", () => {
|
||||
const result = classifyInsightRunError(new DOMException("Aborted", "AbortError"));
|
||||
expect(result.failureClass).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("classifies timeout", () => {
|
||||
const result = classifyInsightRunError(new Error("timed out while calling provider"));
|
||||
expect(result.failureClass).toBe("timed_out");
|
||||
expect(result.retryable).toBe(true);
|
||||
});
|
||||
|
||||
it("classifies transient provider errors as retryable", () => {
|
||||
const result = classifyInsightRunError(new Error("HTTP 503 from provider"));
|
||||
expect(result.failureClass).toBe("retryable_transient");
|
||||
expect(result.retryable).toBe(true);
|
||||
});
|
||||
|
||||
it("classifies deterministic failures as non-retryable", () => {
|
||||
const result = classifyInsightRunError(new Error("invalid JSON contract response"));
|
||||
expect(result.failureClass).toBe("non_retryable");
|
||||
expect(result.retryable).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("executeInsightRunLifecycle", () => {
|
||||
it("completes and persists events", async () => {
|
||||
const store = createStore();
|
||||
const run = await executeInsightRunLifecycle({
|
||||
store,
|
||||
projectId: "proj",
|
||||
input: { trigger: "manual" },
|
||||
executeAttempt: async () => ({
|
||||
summary: "done",
|
||||
insightsCreated: 2,
|
||||
insightsUpdated: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(run.status).toBe("completed");
|
||||
const events = store.listRunEvents(run.id);
|
||||
expect(events.map((event) => event.type)).toEqual(["status_changed", "status_changed", "info", "status_changed"]);
|
||||
});
|
||||
|
||||
it("retries transient failures with bounded attempts", async () => {
|
||||
const store = createStore();
|
||||
let calls = 0;
|
||||
|
||||
const run = await executeInsightRunLifecycle({
|
||||
store,
|
||||
projectId: "proj",
|
||||
input: { trigger: "manual" },
|
||||
maxAttempts: 2,
|
||||
retryDelayMs: 0,
|
||||
executeAttempt: async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
throw new Error("HTTP 503");
|
||||
}
|
||||
return {
|
||||
summary: "recovered",
|
||||
insightsCreated: 1,
|
||||
insightsUpdated: 0,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
expect(calls).toBe(2);
|
||||
expect(run.status).toBe("completed");
|
||||
const events = store.listRunEvents(run.id);
|
||||
expect(events.some((event) => event.type === "retry_scheduled")).toBe(true);
|
||||
});
|
||||
|
||||
it("fails non-retryable errors without retry", async () => {
|
||||
const store = createStore();
|
||||
const run = await executeInsightRunLifecycle({
|
||||
store,
|
||||
projectId: "proj",
|
||||
input: { trigger: "manual" },
|
||||
maxAttempts: 3,
|
||||
executeAttempt: async () => {
|
||||
throw new Error("validation failed");
|
||||
},
|
||||
});
|
||||
|
||||
expect(run.status).toBe("failed");
|
||||
expect(run.lifecycle.failureClass).toBe("non_retryable");
|
||||
expect(run.lifecycle.retryable).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks duplicate active runs for same project+trigger", async () => {
|
||||
const store = createStore();
|
||||
store.createRun("proj", { trigger: "manual" });
|
||||
|
||||
await expect(() => executeInsightRunLifecycle({
|
||||
store,
|
||||
projectId: "proj",
|
||||
input: { trigger: "manual" },
|
||||
executeAttempt: async () => ({ insightsCreated: 0, insightsUpdated: 0 }),
|
||||
})).rejects.toMatchObject({ code: "active_run_conflict" } satisfies Partial<InsightLifecycleError>);
|
||||
});
|
||||
|
||||
it("marks timeout as terminal failure classification", async () => {
|
||||
const store = createStore();
|
||||
const run = await executeInsightRunLifecycle({
|
||||
store,
|
||||
projectId: "proj",
|
||||
input: { trigger: "manual" },
|
||||
timeoutMs: 10,
|
||||
maxAttempts: 1,
|
||||
executeAttempt: async ({ signal }) => {
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(resolve, 50);
|
||||
signal.addEventListener("abort", () => {
|
||||
clearTimeout(timeout);
|
||||
reject(signal.reason ?? new Error("aborted"));
|
||||
});
|
||||
});
|
||||
return { insightsCreated: 0, insightsUpdated: 0 };
|
||||
},
|
||||
});
|
||||
|
||||
expect(run.status).toBe("failed");
|
||||
expect(run.lifecycle.failureClass).toBe("timed_out");
|
||||
});
|
||||
});
|
||||
|
||||
describe("retryInsightRunLifecycle", () => {
|
||||
it("creates a new run from retryable failed run", async () => {
|
||||
const store = createStore();
|
||||
const failed = await executeInsightRunLifecycle({
|
||||
store,
|
||||
projectId: "proj",
|
||||
input: { trigger: "manual" },
|
||||
maxAttempts: 1,
|
||||
executeAttempt: async () => {
|
||||
throw new Error("HTTP 503");
|
||||
},
|
||||
});
|
||||
|
||||
const retried = await retryInsightRunLifecycle({
|
||||
store,
|
||||
runId: failed.id,
|
||||
executeAttempt: async () => ({ insightsCreated: 1, insightsUpdated: 0 }),
|
||||
});
|
||||
|
||||
expect(retried.run.id).not.toBe(failed.id);
|
||||
expect(retried.run.lifecycle.retryOfRunId).toBe(failed.id);
|
||||
expect(retried.run.status).toBe("completed");
|
||||
});
|
||||
|
||||
it("rejects retry for non-retryable failures", async () => {
|
||||
const store = createStore();
|
||||
const failed = await executeInsightRunLifecycle({
|
||||
store,
|
||||
projectId: "proj",
|
||||
input: { trigger: "manual" },
|
||||
maxAttempts: 1,
|
||||
executeAttempt: async () => {
|
||||
throw new Error("invalid input");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(retryInsightRunLifecycle({
|
||||
store,
|
||||
runId: failed.id,
|
||||
executeAttempt: async () => ({ insightsCreated: 1, insightsUpdated: 0 }),
|
||||
})).rejects.toMatchObject({ code: "not_retryable" } satisfies Partial<InsightLifecycleError>);
|
||||
});
|
||||
});
|
||||
@@ -749,16 +749,14 @@ describe("InsightStore Run CRUD", () => {
|
||||
expect(fromDb).toEqual(updated);
|
||||
});
|
||||
|
||||
it("preserves existing completedAt on later updates", () => {
|
||||
it("rejects updates after terminal completion", () => {
|
||||
const run = store.createRun("proj", { trigger: "manual" });
|
||||
const completed = store.updateRun(run.id, { status: "failed", error: "boom" });
|
||||
const firstCompletedAt = completed?.completedAt;
|
||||
expect(completed?.completedAt).toBeTruthy();
|
||||
|
||||
const patched = store.updateRun(run.id, { summary: "postmortem" });
|
||||
|
||||
expect(firstCompletedAt).toBeTruthy();
|
||||
expect(patched?.completedAt).toBe(firstCompletedAt);
|
||||
expect(patched?.summary).toBe("postmortem");
|
||||
expect(() => store.updateRun(run.id, { summary: "postmortem" })).toThrow(
|
||||
/terminal and immutable/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not override completedAt if already provided", () => {
|
||||
@@ -871,7 +869,7 @@ describe("Migration: pre-33 DB upgrade", () => {
|
||||
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
|
||||
const db1 = createDatabase(legacyDir);
|
||||
db1.init();
|
||||
expect(db1.getSchemaVersion()).toBe(59);
|
||||
expect(db1.getSchemaVersion()).toBe(60);
|
||||
db1.close();
|
||||
|
||||
// Step 2: Manually downgrade to version 32 and drop insight tables
|
||||
@@ -906,7 +904,7 @@ describe("Migration: pre-33 DB upgrade", () => {
|
||||
expect(tableNamesBefore).not.toContain("project_insight_runs");
|
||||
// Now run init — this triggers the v32→v33 migration
|
||||
db3.init();
|
||||
expect(db3.getSchemaVersion()).toBe(59);
|
||||
expect(db3.getSchemaVersion()).toBe(60);
|
||||
|
||||
// Step 4: Verify insight tables exist after migration
|
||||
const tablesAfter = db3.prepare(
|
||||
@@ -937,12 +935,12 @@ describe("Migration: pre-33 DB upgrade", () => {
|
||||
try {
|
||||
const db1 = createDatabase(testDir);
|
||||
db1.init();
|
||||
expect(db1.getSchemaVersion()).toBe(59);
|
||||
expect(db1.getSchemaVersion()).toBe(60);
|
||||
db1.close();
|
||||
|
||||
const db2 = createDatabase(testDir);
|
||||
expect(() => db2.init()).not.toThrow();
|
||||
expect(db2.getSchemaVersion()).toBe(59);
|
||||
expect(db2.getSchemaVersion()).toBe(60);
|
||||
db2.close();
|
||||
} finally {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
|
||||
502
packages/core/src/__tests__/mesh-config-generator.test.ts
Normal file
502
packages/core/src/__tests__/mesh-config-generator.test.ts
Normal file
@@ -0,0 +1,502 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ManagedDockerNode, MeshConnectionConfig } from "../types.js";
|
||||
|
||||
// ── Mocks ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockCentral = {
|
||||
getManagedDockerNode: vi.fn(),
|
||||
updateManagedDockerNode: vi.fn(),
|
||||
registerNode: vi.fn(),
|
||||
linkManagedDockerNodeToNode: vi.fn(),
|
||||
checkNodeHealth: vi.fn(),
|
||||
};
|
||||
|
||||
const mockDockerClient = {
|
||||
recreateContainer: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("../central-core.js", () => ({
|
||||
CentralCore: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../docker-client.js", () => ({
|
||||
DockerClientService: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function createManagedNode(overrides: Partial<ManagedDockerNode> = {}): ManagedDockerNode {
|
||||
return {
|
||||
id: "dn_test123",
|
||||
nodeId: null,
|
||||
name: "test-node",
|
||||
imageName: "runfusion/fusion",
|
||||
imageTag: "latest",
|
||||
containerId: "container_abc",
|
||||
status: "creating",
|
||||
hostConfig: { host: undefined },
|
||||
envVars: {},
|
||||
volumeMounts: [],
|
||||
resourceSizing: { memoryMB: 4096, cpus: 2 },
|
||||
extraClis: [],
|
||||
persistentStorage: true,
|
||||
reachableUrl: null,
|
||||
apiKey: null,
|
||||
errorMessage: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Import after mocks are set up
|
||||
const { MeshConfigGenerator } = await import("../mesh-config-generator.js");
|
||||
|
||||
function createGenerator() {
|
||||
return new MeshConfigGenerator({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
central: mockCentral as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
dockerClient: mockDockerClient as any,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MeshConfigGenerator", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── generateConfig ─────────────────────────────────────────────────────
|
||||
|
||||
describe("generateConfig", () => {
|
||||
it("uses managed node's reachableUrl when set", () => {
|
||||
const generator = createGenerator();
|
||||
const node = createManagedNode({ reachableUrl: "http://custom:5000" });
|
||||
|
||||
const config = generator.generateConfig({
|
||||
managedNode: node,
|
||||
orchestratorUrl: "http://orchestrator:4040",
|
||||
orchestratorApiKey: "orch-key",
|
||||
});
|
||||
|
||||
expect(config.reachableUrl).toBe("http://custom:5000");
|
||||
});
|
||||
|
||||
it("auto-generates 32-char hex API key when none provided", () => {
|
||||
const generator = createGenerator();
|
||||
const node = createManagedNode();
|
||||
|
||||
const config = generator.generateConfig({
|
||||
managedNode: node,
|
||||
orchestratorUrl: "http://orchestrator:4040",
|
||||
orchestratorApiKey: "orch-key",
|
||||
});
|
||||
|
||||
expect(config.nodeApiKey).toMatch(/^[0-9a-f]{32}$/);
|
||||
});
|
||||
|
||||
it("preserves user-provided API key", () => {
|
||||
const generator = createGenerator();
|
||||
const node = createManagedNode();
|
||||
|
||||
const config = generator.generateConfig({
|
||||
managedNode: node,
|
||||
orchestratorUrl: "http://orchestrator:4040",
|
||||
orchestratorApiKey: "orch-key",
|
||||
nodeApiKey: "user-provided-key",
|
||||
});
|
||||
|
||||
expect(config.nodeApiKey).toBe("user-provided-key");
|
||||
});
|
||||
|
||||
it("assembles all mesh env vars with correct values", () => {
|
||||
const generator = createGenerator();
|
||||
const node = createManagedNode({ name: "my-node" });
|
||||
|
||||
const config = generator.generateConfig({
|
||||
managedNode: node,
|
||||
orchestratorUrl: "http://orchestrator:4040",
|
||||
orchestratorApiKey: "orch-key",
|
||||
nodeApiKey: "test-api-key",
|
||||
containerPort: 5000,
|
||||
});
|
||||
|
||||
expect(config.envVars).toMatchObject({
|
||||
FUSION_DAEMON_TOKEN: "test-api-key",
|
||||
PORT: "5000",
|
||||
FUSION_NODE_NAME: "my-node",
|
||||
});
|
||||
});
|
||||
|
||||
it("merges with existing user env vars, mesh config overrides on conflict", () => {
|
||||
const generator = createGenerator();
|
||||
const node = createManagedNode({
|
||||
envVars: { PORT: "3000", CUSTOM_VAR: "custom-value" },
|
||||
});
|
||||
|
||||
const config = generator.generateConfig({
|
||||
managedNode: node,
|
||||
orchestratorUrl: "http://orchestrator:4040",
|
||||
orchestratorApiKey: "orch-key",
|
||||
nodeApiKey: "test-key",
|
||||
});
|
||||
|
||||
// User env var preserved
|
||||
expect(config.envVars.CUSTOM_VAR).toBe("custom-value");
|
||||
// Mesh config overrides user PORT
|
||||
expect(config.envVars.PORT).toBe("4041");
|
||||
});
|
||||
|
||||
it("defaults container port to 4041", () => {
|
||||
const generator = createGenerator();
|
||||
const node = createManagedNode();
|
||||
|
||||
const config = generator.generateConfig({
|
||||
managedNode: node,
|
||||
orchestratorUrl: "http://orchestrator:4040",
|
||||
orchestratorApiKey: "orch-key",
|
||||
});
|
||||
|
||||
expect(config.containerPort).toBe(4041);
|
||||
expect(config.envVars.PORT).toBe("4041");
|
||||
});
|
||||
|
||||
it("uses explicit containerPort override", () => {
|
||||
const generator = createGenerator();
|
||||
const node = createManagedNode();
|
||||
|
||||
const config = generator.generateConfig({
|
||||
managedNode: node,
|
||||
orchestratorUrl: "http://orchestrator:4040",
|
||||
orchestratorApiKey: "orch-key",
|
||||
containerPort: 5050,
|
||||
});
|
||||
|
||||
expect(config.containerPort).toBe(5050);
|
||||
});
|
||||
|
||||
it("passes orchestrator URL and API key through to config", () => {
|
||||
const generator = createGenerator();
|
||||
const node = createManagedNode();
|
||||
|
||||
const config = generator.generateConfig({
|
||||
managedNode: node,
|
||||
orchestratorUrl: "http://orchestrator:4040",
|
||||
orchestratorApiKey: "orch-key-123",
|
||||
});
|
||||
|
||||
expect(config.orchestratorUrl).toBe("http://orchestrator:4040");
|
||||
expect(config.orchestratorApiKey).toBe("orch-key-123");
|
||||
});
|
||||
|
||||
it("resolves localhost URL for local Docker daemon", () => {
|
||||
const generator = createGenerator();
|
||||
const node = createManagedNode({ hostConfig: { host: undefined } });
|
||||
|
||||
const config = generator.generateConfig({
|
||||
managedNode: node,
|
||||
orchestratorUrl: "http://orchestrator:4040",
|
||||
orchestratorApiKey: "orch-key",
|
||||
});
|
||||
|
||||
expect(config.reachableUrl).toBe("http://localhost:4041");
|
||||
});
|
||||
|
||||
it("resolves remote host URL from hostConfig", () => {
|
||||
const generator = createGenerator();
|
||||
const node = createManagedNode({
|
||||
hostConfig: { host: "tcp://192.168.1.50:2376" },
|
||||
});
|
||||
|
||||
const config = generator.generateConfig({
|
||||
managedNode: node,
|
||||
orchestratorUrl: "http://orchestrator:4040",
|
||||
orchestratorApiKey: "orch-key",
|
||||
containerPort: 5000,
|
||||
});
|
||||
|
||||
expect(config.reachableUrl).toBe("http://192.168.1.50:5000");
|
||||
});
|
||||
});
|
||||
|
||||
// ── applyConfig ────────────────────────────────────────────────────────
|
||||
|
||||
describe("applyConfig", () => {
|
||||
const config: MeshConnectionConfig = {
|
||||
nodeApiKey: "test-key",
|
||||
reachableUrl: "http://localhost:4041",
|
||||
orchestratorUrl: "http://orchestrator:4040",
|
||||
orchestratorApiKey: "orch-key",
|
||||
containerPort: 4041,
|
||||
envVars: {
|
||||
FUSION_DAEMON_TOKEN: "test-key",
|
||||
PORT: "4041",
|
||||
FUSION_NODE_NAME: "test-node",
|
||||
},
|
||||
};
|
||||
|
||||
it("sets status to recreating, recreates container, updates to running", async () => {
|
||||
const generator = createGenerator();
|
||||
const node = createManagedNode();
|
||||
|
||||
mockCentral.getManagedDockerNode.mockResolvedValue(node);
|
||||
mockDockerClient.recreateContainer.mockResolvedValue("new-container-id");
|
||||
mockCentral.updateManagedDockerNode.mockResolvedValue({
|
||||
...node,
|
||||
status: "running",
|
||||
containerId: "new-container-id",
|
||||
});
|
||||
|
||||
await generator.applyConfig("dn_test123", config, { host: undefined });
|
||||
|
||||
// Status set to "recreating" first
|
||||
expect(mockCentral.updateManagedDockerNode).toHaveBeenCalledWith(
|
||||
"dn_test123",
|
||||
expect.objectContaining({ status: "recreating" }),
|
||||
);
|
||||
|
||||
// Container recreated with correct params
|
||||
expect(mockDockerClient.recreateContainer).toHaveBeenCalledWith(
|
||||
"container_abc",
|
||||
expect.objectContaining({
|
||||
envVars: config.envVars,
|
||||
imageName: "runfusion/fusion:latest",
|
||||
volumeMounts: [],
|
||||
}),
|
||||
);
|
||||
|
||||
// Final update with running status
|
||||
expect(mockCentral.updateManagedDockerNode).toHaveBeenCalledWith(
|
||||
"dn_test123",
|
||||
expect.objectContaining({
|
||||
status: "running",
|
||||
containerId: "new-container-id",
|
||||
apiKey: "test-key",
|
||||
reachableUrl: "http://localhost:4041",
|
||||
envVars: config.envVars,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws descriptive error when node has no containerId", async () => {
|
||||
const generator = createGenerator();
|
||||
const node = createManagedNode({ containerId: null });
|
||||
|
||||
mockCentral.getManagedDockerNode.mockResolvedValue(node);
|
||||
|
||||
await expect(
|
||||
generator.applyConfig("dn_test123", config, { host: undefined }),
|
||||
).rejects.toThrow("has no container ID");
|
||||
});
|
||||
|
||||
it("sets status to error and re-throws when recreation fails", async () => {
|
||||
const generator = createGenerator();
|
||||
const node = createManagedNode();
|
||||
|
||||
mockCentral.getManagedDockerNode.mockResolvedValue(node);
|
||||
mockDockerClient.recreateContainer.mockRejectedValue(new Error("Docker error"));
|
||||
|
||||
await expect(
|
||||
generator.applyConfig("dn_test123", config, { host: undefined }),
|
||||
).rejects.toThrow("Docker error");
|
||||
|
||||
// Status should be set to error
|
||||
expect(mockCentral.updateManagedDockerNode).toHaveBeenCalledWith(
|
||||
"dn_test123",
|
||||
expect.objectContaining({
|
||||
status: "error",
|
||||
errorMessage: "Docker error",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── registerInMesh ────────────────────────────────────────────────────
|
||||
|
||||
describe("registerInMesh", () => {
|
||||
it("registers node, links it, and returns healthy result", async () => {
|
||||
const generator = createGenerator();
|
||||
const node = createManagedNode();
|
||||
const registeredNode = { id: "node_new", name: "test-node", type: "remote" as const };
|
||||
|
||||
mockCentral.getManagedDockerNode.mockResolvedValue(node);
|
||||
mockCentral.registerNode.mockResolvedValue(registeredNode);
|
||||
mockCentral.linkManagedDockerNodeToNode.mockResolvedValue(node);
|
||||
mockCentral.checkNodeHealth.mockResolvedValue("online");
|
||||
|
||||
const config: MeshConnectionConfig = {
|
||||
nodeApiKey: "test-key",
|
||||
reachableUrl: "http://localhost:4041",
|
||||
orchestratorUrl: "http://orchestrator:4040",
|
||||
orchestratorApiKey: "orch-key",
|
||||
containerPort: 4041,
|
||||
envVars: {},
|
||||
};
|
||||
|
||||
const result = await generator.registerInMesh("dn_test123", config);
|
||||
|
||||
expect(mockCentral.registerNode).toHaveBeenCalledWith({
|
||||
name: "test-node",
|
||||
type: "remote",
|
||||
url: "http://localhost:4041",
|
||||
apiKey: "test-key",
|
||||
maxConcurrent: 2,
|
||||
});
|
||||
|
||||
expect(mockCentral.linkManagedDockerNodeToNode).toHaveBeenCalledWith(
|
||||
"dn_test123",
|
||||
"node_new",
|
||||
);
|
||||
|
||||
expect(result.isHealthy).toBe(true);
|
||||
expect(result.node).toBe(registeredNode);
|
||||
expect(result.config).toBe(config);
|
||||
});
|
||||
|
||||
it("returns unhealthy when health check times out", async () => {
|
||||
const generator = createGenerator();
|
||||
const node = createManagedNode();
|
||||
const registeredNode = { id: "node_new", name: "test-node", type: "remote" as const };
|
||||
|
||||
mockCentral.getManagedDockerNode.mockResolvedValue(node);
|
||||
mockCentral.registerNode.mockResolvedValue(registeredNode);
|
||||
mockCentral.linkManagedDockerNodeToNode.mockResolvedValue(node);
|
||||
// Always return "offline" — simulates timeout
|
||||
mockCentral.checkNodeHealth.mockResolvedValue("offline");
|
||||
|
||||
const config: MeshConnectionConfig = {
|
||||
nodeApiKey: "test-key",
|
||||
reachableUrl: "http://localhost:4041",
|
||||
orchestratorUrl: "http://orchestrator:4040",
|
||||
orchestratorApiKey: "orch-key",
|
||||
containerPort: 4041,
|
||||
envVars: {},
|
||||
};
|
||||
|
||||
// Use fake timers to speed up the timeout test
|
||||
vi.useFakeTimers();
|
||||
const resultPromise = generator.registerInMesh("dn_test123", config);
|
||||
|
||||
// Fast-forward through the polling
|
||||
await vi.advanceTimersByTimeAsync(35_000);
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result.isHealthy).toBe(false);
|
||||
expect(result.error).toContain("did not reach online status");
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("re-throws when registration fails", async () => {
|
||||
const generator = createGenerator();
|
||||
const node = createManagedNode();
|
||||
|
||||
mockCentral.getManagedDockerNode.mockResolvedValue(node);
|
||||
mockCentral.registerNode.mockRejectedValue(new Error("Name collision"));
|
||||
|
||||
const config: MeshConnectionConfig = {
|
||||
nodeApiKey: "test-key",
|
||||
reachableUrl: "http://localhost:4041",
|
||||
orchestratorUrl: "http://orchestrator:4040",
|
||||
orchestratorApiKey: "orch-key",
|
||||
containerPort: 4041,
|
||||
envVars: {},
|
||||
};
|
||||
|
||||
await expect(
|
||||
generator.registerInMesh("dn_test123", config),
|
||||
).rejects.toThrow("Name collision");
|
||||
});
|
||||
});
|
||||
|
||||
// ── provisionAndRegister ──────────────────────────────────────────────
|
||||
|
||||
describe("provisionAndRegister", () => {
|
||||
it("runs full end-to-end flow: generate → apply → register", async () => {
|
||||
const generator = createGenerator();
|
||||
const node = createManagedNode();
|
||||
const registeredNode = { id: "node_new", name: "test-node", type: "remote" as const };
|
||||
|
||||
mockCentral.getManagedDockerNode.mockResolvedValue(node);
|
||||
mockDockerClient.recreateContainer.mockResolvedValue("new-container-id");
|
||||
mockCentral.updateManagedDockerNode.mockImplementation((_id: string, updates: Record<string, unknown>) =>
|
||||
Promise.resolve({ ...node, ...updates }),
|
||||
);
|
||||
mockCentral.registerNode.mockResolvedValue(registeredNode);
|
||||
mockCentral.linkManagedDockerNodeToNode.mockResolvedValue(node);
|
||||
mockCentral.checkNodeHealth.mockResolvedValue("online");
|
||||
|
||||
const result = await generator.provisionAndRegister({
|
||||
managedNode: node,
|
||||
orchestratorUrl: "http://orchestrator:4040",
|
||||
orchestratorApiKey: "orch-key",
|
||||
nodeApiKey: "my-key",
|
||||
containerPort: 4041,
|
||||
});
|
||||
|
||||
expect(result.isHealthy).toBe(true);
|
||||
expect(result.config.nodeApiKey).toBe("my-key");
|
||||
expect(result.config.envVars.FUSION_DAEMON_TOKEN).toBe("my-key");
|
||||
expect(result.node).toBe(registeredNode);
|
||||
});
|
||||
|
||||
it("sets managed node to error when apply fails", async () => {
|
||||
const generator = createGenerator();
|
||||
const node = createManagedNode();
|
||||
|
||||
mockCentral.getManagedDockerNode.mockResolvedValue(node);
|
||||
mockCentral.updateManagedDockerNode.mockImplementation((_id: string, updates: Record<string, unknown>) =>
|
||||
Promise.resolve({ ...node, ...updates }),
|
||||
);
|
||||
mockDockerClient.recreateContainer.mockRejectedValue(new Error("Recreate failed"));
|
||||
|
||||
await expect(
|
||||
generator.provisionAndRegister({
|
||||
managedNode: node,
|
||||
orchestratorUrl: "http://orchestrator:4040",
|
||||
orchestratorApiKey: "orch-key",
|
||||
}),
|
||||
).rejects.toThrow("Recreate failed");
|
||||
|
||||
// Error status update should have happened
|
||||
expect(mockCentral.updateManagedDockerNode).toHaveBeenCalledWith(
|
||||
"dn_test123",
|
||||
expect.objectContaining({
|
||||
status: "error",
|
||||
errorMessage: "Recreate failed",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("sets managed node to error when register fails", async () => {
|
||||
const generator = createGenerator();
|
||||
const node = createManagedNode();
|
||||
|
||||
mockCentral.getManagedDockerNode.mockResolvedValue(node);
|
||||
mockCentral.updateManagedDockerNode.mockImplementation((_id: string, updates: Record<string, unknown>) =>
|
||||
Promise.resolve({ ...node, ...updates }),
|
||||
);
|
||||
mockDockerClient.recreateContainer.mockResolvedValue("new-container-id");
|
||||
mockCentral.registerNode.mockRejectedValue(new Error("Registration failed"));
|
||||
|
||||
await expect(
|
||||
generator.provisionAndRegister({
|
||||
managedNode: node,
|
||||
orchestratorUrl: "http://orchestrator:4040",
|
||||
orchestratorApiKey: "orch-key",
|
||||
}),
|
||||
).rejects.toThrow("Registration failed");
|
||||
|
||||
expect(mockCentral.updateManagedDockerNode).toHaveBeenCalledWith(
|
||||
"dn_test123",
|
||||
expect.objectContaining({
|
||||
status: "error",
|
||||
errorMessage: "Registration failed",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2629,7 +2629,7 @@ describe("MissionStore", () => {
|
||||
|
||||
describe("Loop State & Validator Run Schema (v31)", () => {
|
||||
it("schema version is 40 after migration", () => {
|
||||
expect(db.getSchemaVersion()).toBe(59);
|
||||
expect(db.getSchemaVersion()).toBe(60);
|
||||
});
|
||||
|
||||
it("mission_features table has loop state columns", () => {
|
||||
|
||||
101
packages/core/src/__tests__/oauth-credential-interop.test.ts
Normal file
101
packages/core/src/__tests__/oauth-credential-interop.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
choosePreferredStoredCredential,
|
||||
extractCodexCliStoredCredential,
|
||||
readStoredCredentialsFromAuthFile,
|
||||
shouldHydrateStoredCredential,
|
||||
} from "../oauth-credential-interop.js";
|
||||
|
||||
function encodeBase64Url(value: string): string {
|
||||
return Buffer.from(value, "utf-8").toString("base64url");
|
||||
}
|
||||
|
||||
function createJwt(payload: Record<string, unknown>): string {
|
||||
return [
|
||||
encodeBase64Url(JSON.stringify({ alg: "none", typ: "JWT" })),
|
||||
encodeBase64Url(JSON.stringify(payload)),
|
||||
"signature",
|
||||
].join(".");
|
||||
}
|
||||
|
||||
describe("oauth credential interop", () => {
|
||||
it("extracts Codex CLI OAuth credentials from auth.json token payload", () => {
|
||||
const expiresAtSeconds = Math.floor(Date.now() / 1000) + 3600;
|
||||
const accessToken = createJwt({
|
||||
exp: expiresAtSeconds,
|
||||
"https://api.openai.com/auth": {
|
||||
chatgpt_account_id: "acct_123",
|
||||
},
|
||||
});
|
||||
|
||||
const credential = extractCodexCliStoredCredential({
|
||||
tokens: {
|
||||
access_token: accessToken,
|
||||
refresh_token: "refresh-token",
|
||||
},
|
||||
});
|
||||
|
||||
expect(credential).toEqual({
|
||||
type: "oauth",
|
||||
access: accessToken,
|
||||
refresh: "refresh-token",
|
||||
expires: expiresAtSeconds * 1000,
|
||||
accountId: "acct_123",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to last_refresh when Codex CLI JWT has no exp claim", () => {
|
||||
const accessToken = createJwt({
|
||||
sub: "user-123",
|
||||
});
|
||||
const lastRefresh = "2026-05-03T10:00:00.000Z";
|
||||
|
||||
const credential = extractCodexCliStoredCredential({
|
||||
last_refresh: lastRefresh,
|
||||
tokens: {
|
||||
access_token: accessToken,
|
||||
refresh_token: "refresh-token",
|
||||
account_id: "acct_from_token",
|
||||
},
|
||||
});
|
||||
|
||||
expect(credential?.type).toBe("oauth");
|
||||
expect(credential?.accountId).toBe("acct_from_token");
|
||||
expect(credential?.expires).toBe(Date.parse(lastRefresh) + 55 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("prefers a valid OAuth credential over an expired one and hydrates only when better", () => {
|
||||
const expired = {
|
||||
type: "oauth",
|
||||
access: "expired-access",
|
||||
refresh: "expired-refresh",
|
||||
expires: Date.now() - 60_000,
|
||||
} as const;
|
||||
const valid = {
|
||||
type: "oauth",
|
||||
access: "valid-access",
|
||||
refresh: "valid-refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
} as const;
|
||||
|
||||
expect(choosePreferredStoredCredential(expired, valid)).toEqual(valid);
|
||||
expect(shouldHydrateStoredCredential(expired, valid)).toBe(true);
|
||||
expect(shouldHydrateStoredCredential({ type: "api_key", key: "sk-live" }, valid)).toBe(false);
|
||||
});
|
||||
|
||||
it("gracefully ignores malformed auth files", () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "fusion-oauth-interop-"));
|
||||
|
||||
try {
|
||||
const malformedPath = join(tempDir, "auth.json");
|
||||
writeFileSync(malformedPath, "{ not-json");
|
||||
|
||||
expect(readStoredCredentialsFromAuthFile(malformedPath)).toEqual({});
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,18 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { writeFile, mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { mkdtempSync, existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { PluginLoader } from "../plugin-loader.js";
|
||||
|
||||
vi.mock("@mariozechner/pi-ai", () => ({
|
||||
AssistantMessageEventStream: class AssistantMessageEventStream {
|
||||
push() {}
|
||||
end() {}
|
||||
},
|
||||
calculateCost: () => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }),
|
||||
}));
|
||||
import { PluginStore } from "../plugin-store.js";
|
||||
import { setCreateAiSessionFactory } from "../ai-engine-loader.js";
|
||||
import type { CreateAiSessionOptions, FusionPlugin, PluginManifest } from "../plugin-types.js";
|
||||
@@ -105,6 +114,12 @@ function makeTmpDir(): string {
|
||||
return mkdtempSync(join(tmpdir(), "kb-plugin-loader-test-"));
|
||||
}
|
||||
|
||||
function droidPluginModulePath(): string {
|
||||
return fileURLToPath(
|
||||
new URL("../../../../plugins/fusion-plugin-droid-runtime/src/index.ts", import.meta.url),
|
||||
);
|
||||
}
|
||||
|
||||
// Mock TaskStore for testing
|
||||
const mockTaskStore = {
|
||||
logActivity: vi.fn(),
|
||||
@@ -310,6 +325,64 @@ describe("PluginLoader", () => {
|
||||
expect(loader.isPluginLoaded("load-test")).toBe(true);
|
||||
});
|
||||
|
||||
it("loads the migrated Droid plugin through register→loadAllPlugins→loadPlugin pipeline", async () => {
|
||||
await pluginStore.init();
|
||||
|
||||
const droidManifest = {
|
||||
id: "fusion-plugin-droid-runtime",
|
||||
name: "Droid Runtime Plugin",
|
||||
version: "0.1.0",
|
||||
description: "Droid runtime plugin for Fusion",
|
||||
runtime: {
|
||||
runtimeId: "droid",
|
||||
name: "Droid Runtime",
|
||||
description: "Drives the Droid CLI for Fusion agents",
|
||||
version: "0.1.0",
|
||||
},
|
||||
} as const;
|
||||
|
||||
await pluginStore.registerPlugin({
|
||||
manifest: droidManifest,
|
||||
path: droidPluginModulePath(),
|
||||
});
|
||||
|
||||
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
|
||||
const loadAllResult = await loader.loadAllPlugins();
|
||||
|
||||
expect(loadAllResult).toEqual({ loaded: 1, errors: 0 });
|
||||
expect(loader.isPluginLoaded("fusion-plugin-droid-runtime")).toBe(true);
|
||||
|
||||
const loaded = await loader.loadPlugin("fusion-plugin-droid-runtime");
|
||||
expect(loaded.manifest.id).toBe("fusion-plugin-droid-runtime");
|
||||
expect(loaded.state).toBe("started");
|
||||
|
||||
const installed = await pluginStore.getPlugin("fusion-plugin-droid-runtime");
|
||||
expect(installed.state).toBe("started");
|
||||
|
||||
const slots = loader
|
||||
.getPluginUiSlots()
|
||||
.filter((entry) => entry.pluginId === "fusion-plugin-droid-runtime");
|
||||
expect(slots.map((entry) => entry.slot.slotId)).toEqual([
|
||||
"onboarding-provider-card",
|
||||
"onboarding-setup-help",
|
||||
"post-onboarding-recommendation",
|
||||
"settings-provider-card",
|
||||
]);
|
||||
expect(slots[0]?.slot).toHaveProperty("label");
|
||||
expect(slots[0]?.slot).toHaveProperty("componentPath");
|
||||
|
||||
const runtimes = loader
|
||||
.getPluginRuntimes()
|
||||
.filter((entry) => entry.pluginId === "fusion-plugin-droid-runtime");
|
||||
expect(runtimes).toHaveLength(1);
|
||||
expect(runtimes[0].runtime.metadata).toMatchObject({
|
||||
runtimeId: "droid",
|
||||
name: "Droid Runtime",
|
||||
version: "0.1.0",
|
||||
});
|
||||
expect(typeof runtimes[0].runtime.factory).toBe("function");
|
||||
});
|
||||
|
||||
it("updates plugin state to started", async () => {
|
||||
await pluginStore.init();
|
||||
|
||||
@@ -415,6 +488,56 @@ describe("PluginLoader", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("fails when plugin module manifest is invalid", async () => {
|
||||
await pluginStore.init();
|
||||
|
||||
const pluginDir = join(rootDir, "plugins");
|
||||
const pluginPath = join(pluginDir, "invalid-manifest.js");
|
||||
await mkdir(pluginDir, { recursive: true });
|
||||
await writeFile(
|
||||
pluginPath,
|
||||
`
|
||||
const plugin = {
|
||||
manifest: { id: "invalid-manifest", version: "1.0.0" },
|
||||
state: "installed",
|
||||
hooks: {},
|
||||
};
|
||||
export default plugin;
|
||||
`,
|
||||
);
|
||||
|
||||
await pluginStore.registerPlugin({
|
||||
manifest: makeManifest({ id: "invalid-manifest" }),
|
||||
path: pluginPath,
|
||||
});
|
||||
|
||||
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
|
||||
|
||||
await expect(loader.loadPlugin("invalid-manifest")).rejects.toThrow(
|
||||
"Invalid plugin manifest",
|
||||
);
|
||||
|
||||
const stored = await pluginStore.getPlugin("invalid-manifest");
|
||||
expect(stored.state).toBe("error");
|
||||
});
|
||||
|
||||
it("fails when plugin entrypoint is missing", async () => {
|
||||
await pluginStore.init();
|
||||
|
||||
const missingPath = join(rootDir, "plugins", "missing-entrypoint.js");
|
||||
await pluginStore.registerPlugin({
|
||||
manifest: makeManifest({ id: "missing-entrypoint" }),
|
||||
path: missingPath,
|
||||
});
|
||||
|
||||
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
|
||||
|
||||
await expect(loader.loadPlugin("missing-entrypoint")).rejects.toThrow();
|
||||
const stored = await pluginStore.getPlugin("missing-entrypoint");
|
||||
expect(stored.state).toBe("error");
|
||||
expect(stored.error).toBeTruthy();
|
||||
});
|
||||
|
||||
it("error isolation - plugin crash during load doesn't crash loader", async () => {
|
||||
await pluginStore.init();
|
||||
|
||||
@@ -487,6 +610,28 @@ describe("PluginLoader", () => {
|
||||
expect(loader.isPluginLoaded("all-b")).toBe(true);
|
||||
});
|
||||
|
||||
it("skips disabled plugins during loadAllPlugins", async () => {
|
||||
await pluginStore.init();
|
||||
|
||||
const pluginDir = join(rootDir, "plugins");
|
||||
const enabledPlugin = makePlugin(makeManifest({ id: "enabled-plugin" }));
|
||||
const disabledPlugin = makePlugin(makeManifest({ id: "disabled-plugin" }));
|
||||
|
||||
const enabledPath = await writePluginModule(pluginDir, "enabled.js", enabledPlugin);
|
||||
const disabledPath = await writePluginModule(pluginDir, "disabled.js", disabledPlugin);
|
||||
|
||||
await pluginStore.registerPlugin({ manifest: enabledPlugin.manifest, path: enabledPath });
|
||||
await pluginStore.registerPlugin({ manifest: disabledPlugin.manifest, path: disabledPath });
|
||||
await pluginStore.disablePlugin("disabled-plugin");
|
||||
|
||||
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
|
||||
const result = await loader.loadAllPlugins();
|
||||
|
||||
expect(result).toEqual({ loaded: 1, errors: 0 });
|
||||
expect(loader.isPluginLoaded("enabled-plugin")).toBe(true);
|
||||
expect(loader.isPluginLoaded("disabled-plugin")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns error count for failed plugins", async () => {
|
||||
await pluginStore.init();
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
readProjectMemory,
|
||||
readProjectMemoryWithBackend,
|
||||
searchProjectMemory,
|
||||
getProjectMemory,
|
||||
resolveMemoryInstructionContext,
|
||||
} from "../project-memory.js";
|
||||
|
||||
@@ -649,4 +650,45 @@ describe("project-memory", () => {
|
||||
expect(results.some((result) => result.path === ".fusion/memory/DREAMS.md")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getProjectMemory", () => {
|
||||
it("reads bounded memory window via file backend", async () => {
|
||||
const memoryDir = join(testDir, ".fusion", "memory");
|
||||
await mkdir(memoryDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(memoryDir, "MEMORY.md"),
|
||||
"# Memory\nline-a\nline-b\nline-c\nline-d\n",
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const result = await getProjectMemory(
|
||||
testDir,
|
||||
{ path: ".fusion/memory/MEMORY.md", startLine: 2, lineCount: 2 },
|
||||
{ memoryBackendType: "file" },
|
||||
);
|
||||
|
||||
expect(result.path).toBe(".fusion/memory/MEMORY.md");
|
||||
expect(result.content).toBe("line-a\nline-b");
|
||||
expect(result.startLine).toBe(2);
|
||||
expect(result.endLine).toBe(3);
|
||||
expect(result.totalLines).toBeGreaterThanOrEqual(5);
|
||||
expect(result.backend).toBe("file");
|
||||
});
|
||||
|
||||
it("returns qmd backend marker for memory_get contract", async () => {
|
||||
const memoryDir = join(testDir, ".fusion", "memory");
|
||||
await mkdir(memoryDir, { recursive: true });
|
||||
await writeFile(join(memoryDir, "MEMORY.md"), "# Memory\nqmd-line\n", "utf-8");
|
||||
|
||||
const result = await getProjectMemory(
|
||||
testDir,
|
||||
{ path: ".fusion/memory/MEMORY.md", startLine: 1, lineCount: 5 },
|
||||
{ memoryBackendType: "qmd" },
|
||||
);
|
||||
|
||||
expect(result.path).toBe(".fusion/memory/MEMORY.md");
|
||||
expect(result.content).toContain("qmd-line");
|
||||
expect(result.backend).toBe("qmd");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { mkdtempSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { createDatabase, type Database } from "../db.js";
|
||||
import { ResearchStore } from "../research-store.js";
|
||||
import { ResearchLifecycleError, ResearchStore } from "../research-store.js";
|
||||
|
||||
describe("ResearchStore", () => {
|
||||
let db: Database;
|
||||
@@ -24,7 +24,7 @@ describe("ResearchStore", () => {
|
||||
const updated = store.updateRun(run.id, { topic: "new topic", error: "oops" });
|
||||
expect(updated?.topic).toBe("new topic");
|
||||
|
||||
const listed = store.listRuns({ status: "pending" });
|
||||
const listed = store.listRuns({ status: "queued" });
|
||||
expect(listed.map((r) => r.id)).toContain(run.id);
|
||||
|
||||
expect(store.deleteRun(run.id)).toBe(true);
|
||||
@@ -51,6 +51,36 @@ describe("ResearchStore", () => {
|
||||
expect(store.getRun(cancelled.id)?.cancelledAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("enforces terminal immutability and valid transitions", () => {
|
||||
const run = store.createRun({ query: "guarded" });
|
||||
store.updateStatus(run.id, "running");
|
||||
store.updateStatus(run.id, "completed");
|
||||
|
||||
expect(() => store.updateRun(run.id, { topic: "changed" })).toThrow(ResearchLifecycleError);
|
||||
|
||||
const queued = store.createRun({ query: "queued" });
|
||||
expect(() => store.updateStatus(queued.id, "completed")).toThrow(/Invalid run status transition/i);
|
||||
});
|
||||
|
||||
it("persists lifecycle events to research_run_events", () => {
|
||||
const run = store.createRun({ query: "events" });
|
||||
store.updateStatus(run.id, "running");
|
||||
store.appendLifecycleEvent(run.id, { type: "info", message: "custom event" });
|
||||
|
||||
const events = store.listRunEvents(run.id);
|
||||
expect(events.length).toBeGreaterThanOrEqual(2);
|
||||
expect(events.at(-1)?.message).toBe("custom event");
|
||||
});
|
||||
|
||||
it("guards against duplicate active runs per project and trigger", () => {
|
||||
const run = store.createRun({ query: "r1", projectId: "p1", trigger: "manual" });
|
||||
expect(store.getActiveRun("p1", "manual")?.id).toBe(run.id);
|
||||
expect(() => store.assertNoActiveRun("p1", "manual")).toThrow(ResearchLifecycleError);
|
||||
|
||||
store.updateStatus(run.id, "cancelled");
|
||||
expect(() => store.assertNoActiveRun("p1", "manual")).not.toThrow();
|
||||
});
|
||||
|
||||
it("appends events, manages sources, and sets results", () => {
|
||||
const run = store.createRun({ query: "events" });
|
||||
const event = store.appendEvent(run.id, { type: "info", message: "started" });
|
||||
@@ -91,6 +121,7 @@ describe("ResearchStore", () => {
|
||||
expect(store.getExport("REXP-missing")).toBeUndefined();
|
||||
|
||||
store.updateStatus(r1.id, "running");
|
||||
store.updateStatus(r2.id, "running");
|
||||
store.updateStatus(r2.id, "completed");
|
||||
const stats = store.getStats();
|
||||
expect(stats.total).toBeGreaterThanOrEqual(2);
|
||||
@@ -100,6 +131,32 @@ describe("ResearchStore", () => {
|
||||
expect(store.getExports(r1.id)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("supports idempotent cancellation request transition", () => {
|
||||
const run = store.createRun({ query: "cancel me" });
|
||||
const first = store.requestCancellation(run.id);
|
||||
expect(first.status).toBe("cancelling");
|
||||
const second = store.requestCancellation(run.id);
|
||||
expect(second.status).toBe("cancelling");
|
||||
|
||||
store.updateStatus(run.id, "cancelled");
|
||||
const terminal = store.requestCancellation(run.id);
|
||||
expect(terminal.status).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("marks retry exhaustion when max attempts reached", () => {
|
||||
const run = store.createRun({ query: "retry", lifecycle: { attempt: 3, maxAttempts: 3 } });
|
||||
store.updateStatus(run.id, "failed", {
|
||||
lifecycle: {
|
||||
...(run.lifecycle ?? {}),
|
||||
retryable: true,
|
||||
failureClass: "retryable_transient",
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => store.createRetryRun(run.id)).toThrow(/non-retryable|exhausted retries/i);
|
||||
expect(store.getRun(run.id)?.status).toBe("retry_exhausted");
|
||||
});
|
||||
|
||||
it("emits status events and throws for missing run mutations", () => {
|
||||
const onStatus = vi.fn();
|
||||
const onCompleted = vi.fn();
|
||||
@@ -107,6 +164,7 @@ describe("ResearchStore", () => {
|
||||
store.on("run:completed", onCompleted);
|
||||
|
||||
const run = store.createRun({ query: "events" });
|
||||
store.updateStatus(run.id, "running");
|
||||
store.updateStatus(run.id, "completed");
|
||||
expect(onStatus).toHaveBeenCalled();
|
||||
expect(onCompleted).toHaveBeenCalled();
|
||||
|
||||
@@ -742,7 +742,7 @@ describe("RoadmapStore", () => {
|
||||
|
||||
describe("schema version", () => {
|
||||
it("schema version is 40 after init", () => {
|
||||
expect(db.getSchemaVersion()).toBe(59);
|
||||
expect(db.getSchemaVersion()).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -465,7 +465,7 @@ describe("Run Audit", () => {
|
||||
});
|
||||
|
||||
it("schema version is bumped to 40", () => {
|
||||
expect(db.getSchemaVersion()).toBe(59);
|
||||
expect(db.getSchemaVersion()).toBe(60);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -480,6 +480,33 @@ describe("TaskStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("pausedByAgentId persistence", () => {
|
||||
it("creates and lists a task with pausedByAgentId", async () => {
|
||||
const task = await store.createTask({ description: "Agent paused task" });
|
||||
const updated = await store.updateTask(task.id, { pausedByAgentId: "agent-1" });
|
||||
|
||||
expect(updated.pausedByAgentId).toBe("agent-1");
|
||||
|
||||
const detail = await store.getTask(task.id);
|
||||
expect(detail.pausedByAgentId).toBe("agent-1");
|
||||
|
||||
const tasks = await store.listTasks();
|
||||
const listed = tasks.find((t) => t.id === task.id);
|
||||
expect(listed?.pausedByAgentId).toBe("agent-1");
|
||||
});
|
||||
|
||||
it("clears pausedByAgentId with null via updateTask", async () => {
|
||||
const task = await store.createTask({ description: "Clear agent pause marker" });
|
||||
await store.updateTask(task.id, { pausedByAgentId: "agent-2" });
|
||||
|
||||
const cleared = await store.updateTask(task.id, { pausedByAgentId: null });
|
||||
expect(cleared.pausedByAgentId).toBeUndefined();
|
||||
|
||||
const detail = await store.getTask(task.id);
|
||||
expect(detail.pausedByAgentId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("nodeId persistence", () => {
|
||||
it("creates a task with nodeId when provided", async () => {
|
||||
const task = await store.createTask({
|
||||
@@ -592,6 +619,36 @@ describe("TaskStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTasksByAssignedAgent", () => {
|
||||
it("returns only tasks assigned to the requested agent", async () => {
|
||||
const mine = await store.createTask({ description: "mine", assignedAgentId: "agent-1" });
|
||||
await store.createTask({ description: "other", assignedAgentId: "agent-2" });
|
||||
await store.createTask({ description: "unassigned" });
|
||||
|
||||
const tasks = await store.getTasksByAssignedAgent("agent-1");
|
||||
expect(tasks.map((task) => task.id)).toEqual([mine.id]);
|
||||
});
|
||||
|
||||
it("supports pausedOnly filter", async () => {
|
||||
const paused = await store.createTask({ description: "paused", assignedAgentId: "agent-1" });
|
||||
const active = await store.createTask({ description: "active", assignedAgentId: "agent-1" });
|
||||
await store.updateTask(paused.id, { paused: true });
|
||||
|
||||
const tasks = await store.getTasksByAssignedAgent("agent-1", { pausedOnly: true });
|
||||
expect(tasks.map((task) => task.id)).toEqual([paused.id]);
|
||||
expect(tasks.some((task) => task.id === active.id)).toBe(false);
|
||||
});
|
||||
|
||||
it("supports excludeArchived filter", async () => {
|
||||
const active = await store.createTask({ description: "active", assignedAgentId: "agent-1" });
|
||||
const archived = await store.createTask({ description: "archived", assignedAgentId: "agent-1", column: "done" });
|
||||
await store.archiveTask(archived.id, false);
|
||||
|
||||
const tasks = await store.getTasksByAssignedAgent("agent-1", { excludeArchived: true });
|
||||
expect(tasks.map((task) => task.id)).toEqual([active.id]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectNextTaskForAgent", () => {
|
||||
it("returns null when no tasks exist", async () => {
|
||||
await expect(store.selectNextTaskForAgent("agent-1")).resolves.toBeNull();
|
||||
@@ -3546,6 +3603,39 @@ describe("TaskStore", () => {
|
||||
fetched = await store.getTask(task.id);
|
||||
expect(fetched.paused).toBe(true);
|
||||
});
|
||||
|
||||
it("sets pausedByAgentId and logs agent pause reason", async () => {
|
||||
const task = await createTestTask();
|
||||
const paused = await store.pauseTask(task.id, true, undefined, { pausedByAgentId: "agent-1" });
|
||||
|
||||
expect(paused.pausedByAgentId).toBe("agent-1");
|
||||
expect(paused.log.at(-1)?.action).toBe("Task paused (agent agent-1 paused)");
|
||||
});
|
||||
|
||||
it("clears pausedByAgentId and logs agent resume reason", async () => {
|
||||
const task = await createTestTask();
|
||||
await store.pauseTask(task.id, true, undefined, { pausedByAgentId: "agent-2" });
|
||||
|
||||
const unpaused = await store.pauseTask(task.id, false);
|
||||
expect(unpaused.pausedByAgentId).toBeUndefined();
|
||||
expect(unpaused.log.at(-1)?.action).toBe("Task unpaused (agent agent-2 resumed)");
|
||||
});
|
||||
|
||||
it("uses standard unpause log when task was not paused by an agent", async () => {
|
||||
const task = await createTestTask();
|
||||
await store.pauseTask(task.id, true);
|
||||
|
||||
const unpaused = await store.pauseTask(task.id, false);
|
||||
expect(unpaused.pausedByAgentId).toBeUndefined();
|
||||
expect(unpaused.log.at(-1)?.action).toBe("Task unpaused");
|
||||
});
|
||||
|
||||
it("keeps pausedByAgentId undefined when pausing without agent options", async () => {
|
||||
const task = await createTestTask();
|
||||
const paused = await store.pauseTask(task.id, true);
|
||||
|
||||
expect(paused.pausedByAgentId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateTask — paused", () => {
|
||||
|
||||
@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
|
||||
|
||||
expect(tableNames.has("task_documents")).toBe(true);
|
||||
expect(tableNames.has("task_document_revisions")).toBe(true);
|
||||
expect(db.getSchemaVersion()).toBe(59);
|
||||
expect(db.getSchemaVersion()).toBe(60);
|
||||
|
||||
const index = db
|
||||
.prepare(
|
||||
|
||||
56
packages/core/src/__tests__/vitest-workers.test.ts
Normal file
56
packages/core/src/__tests__/vitest-workers.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cpus } from "node:os";
|
||||
import { computeMaxWorkers } from "../__test-utils__/vitest-workers";
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
describe("computeMaxWorkers", () => {
|
||||
afterEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
});
|
||||
|
||||
it("uses explicit VITEST_MAX_WORKERS for package-scoped runs", () => {
|
||||
process.env.VITEST_MAX_WORKERS = "4";
|
||||
delete process.env.FUSION_TEST_TOTAL_WORKERS;
|
||||
delete process.env.FUSION_TEST_CONCURRENCY;
|
||||
|
||||
const workers = computeMaxWorkers({ defaultCap: 2 });
|
||||
const cpuCap = Math.max(1, cpus().length - 1);
|
||||
|
||||
expect(workers).toBe(Math.min(4, cpuCap));
|
||||
expect(process.env.VITEST_MAX_WORKERS).toBe(String(workers));
|
||||
});
|
||||
|
||||
it("clamps explicit VITEST_MAX_WORKERS to workspace per-package budget", () => {
|
||||
process.env.VITEST_MAX_WORKERS = "4";
|
||||
process.env.FUSION_TEST_TOTAL_WORKERS = "4";
|
||||
process.env.FUSION_TEST_CONCURRENCY = "2";
|
||||
|
||||
const workers = computeMaxWorkers({ defaultCap: 2 });
|
||||
|
||||
expect(workers).toBe(2);
|
||||
expect(process.env.VITEST_MAX_WORKERS).toBe("2");
|
||||
});
|
||||
|
||||
it("still derives workers from workspace budget when explicit override is absent", () => {
|
||||
delete process.env.VITEST_MAX_WORKERS;
|
||||
process.env.FUSION_TEST_TOTAL_WORKERS = "6";
|
||||
process.env.FUSION_TEST_CONCURRENCY = "2";
|
||||
|
||||
const workers = computeMaxWorkers({ defaultCap: 2 });
|
||||
|
||||
expect(workers).toBe(3);
|
||||
expect(process.env.VITEST_MAX_WORKERS).toBe("3");
|
||||
});
|
||||
|
||||
it("ignores invalid env values and falls back to default cap", () => {
|
||||
process.env.VITEST_MAX_WORKERS = "abc";
|
||||
process.env.FUSION_TEST_TOTAL_WORKERS = "0";
|
||||
process.env.FUSION_TEST_CONCURRENCY = "-1";
|
||||
|
||||
const workers = computeMaxWorkers({ defaultCap: 2 });
|
||||
|
||||
expect(workers).toBe(2);
|
||||
expect(process.env.VITEST_MAX_WORKERS).toBe("2");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user