Adds a local runtime manager to the desktop app (FN-3404) — an embedded engine process managed via IPC with packaging for bundled dependencies and lifecycle documentation. Also includes project mapping onboarding UI with persistence and rollback (FN-3505), plus related dashboard API and hook updates Fusion-Task-Id: FN-3404
158 lines
4.8 KiB
TypeScript
158 lines
4.8 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import type { Server } from "node:http";
|
|
|
|
class FakeServer {
|
|
private listeners = new Map<string, Array<(...args: unknown[]) => void>>();
|
|
constructor(private readonly port: number) {}
|
|
|
|
on(event: string, handler: (...args: unknown[]) => void): this {
|
|
const current = this.listeners.get(event) ?? [];
|
|
current.push(handler);
|
|
this.listeners.set(event, current);
|
|
return this;
|
|
}
|
|
|
|
once(event: string, handler: (...args: unknown[]) => void): this {
|
|
const wrapped = (...args: unknown[]) => {
|
|
this.removeListener(event, wrapped);
|
|
handler(...args);
|
|
};
|
|
return this.on(event, wrapped);
|
|
}
|
|
|
|
removeListener(event: string, handler: (...args: unknown[]) => void): this {
|
|
const current = this.listeners.get(event) ?? [];
|
|
this.listeners.set(event, current.filter((item) => item !== handler));
|
|
return this;
|
|
}
|
|
|
|
emit(event: string, ...args: unknown[]): void {
|
|
for (const handler of this.listeners.get(event) ?? []) {
|
|
handler(...args);
|
|
}
|
|
}
|
|
|
|
address() {
|
|
return { port: this.port };
|
|
}
|
|
|
|
close(callback: () => void): void {
|
|
callback();
|
|
}
|
|
}
|
|
|
|
describe("LocalRuntimeManager", () => {
|
|
const store = {
|
|
init: vi.fn(async () => undefined),
|
|
watch: vi.fn(async () => undefined),
|
|
close: vi.fn(),
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("starts embedded local runtime and reports status", async () => {
|
|
const { LocalRuntimeManager } = await import("../local-runtime.ts");
|
|
const server = new FakeServer(4545);
|
|
const manager = new LocalRuntimeManager({
|
|
rootDir: "/repo",
|
|
createStore: async () => store,
|
|
createDashboardServer: async () => {
|
|
setTimeout(() => server.emit("listening"), 0);
|
|
return server as unknown as Server;
|
|
},
|
|
});
|
|
|
|
const status = await manager.startLocal();
|
|
|
|
expect(store.init).toHaveBeenCalledTimes(1);
|
|
expect(store.watch).toHaveBeenCalledTimes(1);
|
|
expect(status).toMatchObject({
|
|
source: "embedded-local",
|
|
state: "running",
|
|
port: 4545,
|
|
baseUrl: "http://127.0.0.1:4545",
|
|
});
|
|
expect(manager.getServerPort()).toBe(4545);
|
|
});
|
|
|
|
it("returns external-cli status without starting embedded runtime", async () => {
|
|
const { LocalRuntimeManager } = await import("../local-runtime.ts");
|
|
const manager = new LocalRuntimeManager({
|
|
rootDir: "/repo",
|
|
getExternalPort: () => 7777,
|
|
createStore: async () => store,
|
|
createDashboardServer: async () => new FakeServer(4545) as unknown as Server,
|
|
});
|
|
|
|
const status = await manager.startLocal();
|
|
|
|
expect(status).toMatchObject({
|
|
source: "external-cli",
|
|
state: "running",
|
|
port: 7777,
|
|
baseUrl: "http://127.0.0.1:7777",
|
|
});
|
|
expect(store.init).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rolls back and exposes error status when startup fails", async () => {
|
|
const { LocalRuntimeManager } = await import("../local-runtime.ts");
|
|
store.init.mockRejectedValueOnce(new Error("init failed"));
|
|
const manager = new LocalRuntimeManager({
|
|
rootDir: "/repo",
|
|
createStore: async () => store,
|
|
});
|
|
|
|
await expect(manager.startLocal()).rejects.toThrow("init failed");
|
|
expect(store.close).toHaveBeenCalledTimes(1);
|
|
expect(manager.getStatus()).toMatchObject({
|
|
source: "embedded-local",
|
|
state: "error",
|
|
error: "init failed",
|
|
});
|
|
});
|
|
|
|
it("stopLocal is idempotent and no-op when inactive", async () => {
|
|
const { LocalRuntimeManager } = await import("../local-runtime.ts");
|
|
const server = new FakeServer(4545);
|
|
const closeSpy = vi.spyOn(server, "close");
|
|
const manager = new LocalRuntimeManager({
|
|
rootDir: "/repo",
|
|
createStore: async () => store,
|
|
createDashboardServer: async () => {
|
|
setTimeout(() => server.emit("listening"), 0);
|
|
return server as unknown as Server;
|
|
},
|
|
});
|
|
|
|
await manager.startLocal();
|
|
await manager.stopLocal();
|
|
await manager.stopLocal();
|
|
|
|
expect(closeSpy).toHaveBeenCalledTimes(1);
|
|
expect(store.close).toHaveBeenCalledTimes(1);
|
|
expect(manager.getStatus()).toEqual({ source: "none", state: "stopped" });
|
|
});
|
|
|
|
it("startLocal while already running returns current status", async () => {
|
|
const { LocalRuntimeManager } = await import("../local-runtime.ts");
|
|
const server = new FakeServer(4545);
|
|
const manager = new LocalRuntimeManager({
|
|
rootDir: "/repo",
|
|
createStore: async () => store,
|
|
createDashboardServer: async () => {
|
|
setTimeout(() => server.emit("listening"), 0);
|
|
return server as unknown as Server;
|
|
},
|
|
});
|
|
|
|
const first = await manager.startLocal();
|
|
const second = await manager.startLocal();
|
|
|
|
expect(first).toEqual(second);
|
|
expect(store.init).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|