feat(FN-3404): add local embedded runtime manager to desktop app
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
This commit is contained in:
@@ -124,21 +124,25 @@ describe("ipc handlers", () => {
|
||||
expect(channels.has("shell:saveProfile")).toBe(true);
|
||||
expect(channels.has("shell:getDesktopModeState")).toBe(true);
|
||||
expect(channels.has("shell:setDesktopMode")).toBe(true);
|
||||
expect(channels.has("desktopRuntime:getStatus")).toBe(true);
|
||||
expect(channels.has("desktopRuntime:startLocal")).toBe(true);
|
||||
expect(channels.has("desktopRuntime:stopLocal")).toBe(true);
|
||||
expect(channels.has("platform:get")).toBe(true);
|
||||
});
|
||||
|
||||
it("shell:getState returns desktop shell state", async () => {
|
||||
await registerHandlers();
|
||||
await registerHandlers({ getRuntimeStatus: () => ({ source: "none", state: "stopped" }) });
|
||||
const handler = mocks.ipcHandlers.get("shell:getState");
|
||||
const result = await handler?.({});
|
||||
|
||||
expect(result).toMatchObject({ host: "desktop-shell", desktopMode: "remote" });
|
||||
expect(result).toMatchObject({ desktopModeState: { isFirstRun: false, desktopMode: "remote" } });
|
||||
expect(result).toMatchObject({ localRuntime: { source: "none", state: "stopped" } });
|
||||
});
|
||||
|
||||
it("shell:setDesktopMode persists mode and emits state", async () => {
|
||||
const onDesktopModeChange = vi.fn(async () => undefined);
|
||||
const { window } = await registerHandlers({ onDesktopModeChange });
|
||||
const { window } = await registerHandlers({ onDesktopModeChange, getRuntimeStatus: () => ({ source: "none", state: "stopped" }) });
|
||||
const handler = mocks.ipcHandlers.get("shell:setDesktopMode");
|
||||
await handler?.({}, "local");
|
||||
|
||||
@@ -149,6 +153,18 @@ describe("ipc handlers", () => {
|
||||
expect(window.webContents.send).toHaveBeenCalledWith("shell:state", expect.any(Object));
|
||||
});
|
||||
|
||||
it("desktopRuntime start/stop/getStatus handlers proxy runtime manager", async () => {
|
||||
const getRuntimeStatus = vi.fn(() => ({ source: "none", state: "stopped" }));
|
||||
const startLocalRuntime = vi.fn(async () => ({ source: "embedded-local", state: "running", port: 4510 }));
|
||||
const stopLocalRuntime = vi.fn(async () => ({ source: "embedded-local", state: "running", port: 9999 }));
|
||||
|
||||
await registerHandlers({ getRuntimeStatus, startLocalRuntime, stopLocalRuntime });
|
||||
|
||||
await expect(mocks.ipcHandlers.get("desktopRuntime:getStatus")?.({})).resolves.toEqual({ source: "none", state: "stopped" });
|
||||
await expect(mocks.ipcHandlers.get("desktopRuntime:startLocal")?.({})).resolves.toEqual({ source: "embedded-local", state: "running", port: 4510 });
|
||||
await expect(mocks.ipcHandlers.get("desktopRuntime:stopLocal")?.({})).resolves.toEqual({ source: "embedded-local", state: "running", port: 9999 });
|
||||
});
|
||||
|
||||
it("shell:saveProfile persists the helper-generated profile", async () => {
|
||||
await registerHandlers();
|
||||
const handler = mocks.ipcHandlers.get("shell:saveProfile");
|
||||
|
||||
157
packages/desktop/src/__tests__/local-runtime.test.ts
Normal file
157
packages/desktop/src/__tests__/local-runtime.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -26,14 +26,14 @@ const mocks = vi.hoisted(() => {
|
||||
const BrowserWindow = vi.fn(() => browserWindow);
|
||||
const Tray = vi.fn(() => ({ destroy: vi.fn() }));
|
||||
|
||||
const localServerManager = {
|
||||
start: vi.fn(async () => undefined),
|
||||
stop: vi.fn(async () => undefined),
|
||||
getState: vi.fn(() => ({ status: "idle", error: null })),
|
||||
getPort: vi.fn(() => undefined),
|
||||
const localRuntimeManager = {
|
||||
startLocal: vi.fn(async () => ({ source: "embedded-local", state: "running", port: 4041 })),
|
||||
stopLocal: vi.fn(async () => ({ source: "none", state: "stopped" })),
|
||||
getStatus: vi.fn(() => ({ source: "none", state: "stopped" })),
|
||||
getServerPort: vi.fn(() => undefined),
|
||||
};
|
||||
|
||||
return { app, appHandlers, BrowserWindow, Tray, browserWindow, localServerManager };
|
||||
return { app, appHandlers, BrowserWindow, Tray, browserWindow, localRuntimeManager };
|
||||
});
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
@@ -49,28 +49,22 @@ vi.mock("../tray.js", () => ({ setupTray: vi.fn() }));
|
||||
vi.mock("../ipc.js", () => ({ registerIpcHandlers: vi.fn() }));
|
||||
vi.mock("../native.js", () => ({ DEFAULT_WINDOW_STATE: { width: 1000, height: 800 }, loadWindowState: vi.fn(async () => null), saveWindowState: vi.fn(), setupAutoUpdater: vi.fn() }));
|
||||
vi.mock("../deep-link.js", () => ({ registerDeepLinkProtocol: vi.fn(), setupDeepLinkHandler: vi.fn() }));
|
||||
vi.mock("../shell-settings.js", () => ({
|
||||
readShellSettings: vi.fn(async () => ({
|
||||
desktopMode: "local",
|
||||
hasCompletedModeSelection: true,
|
||||
activeProfileId: null,
|
||||
profiles: [],
|
||||
})),
|
||||
getDesktopShellModeState: () => ({ isFirstRun: false, desktopMode: "local" }),
|
||||
}));
|
||||
vi.mock("../local-server.js", () => ({ DesktopLocalServerManager: vi.fn(() => mocks.localServerManager) }));
|
||||
vi.mock("../local-runtime.js", () => ({ LocalRuntimeManager: vi.fn(() => mocks.localRuntimeManager) }));
|
||||
|
||||
describe("main local mode", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
mocks.appHandlers.clear();
|
||||
delete process.env.FUSION_DESKTOP_MODE;
|
||||
});
|
||||
|
||||
it("starts local server manager when restored desktop mode is local", async () => {
|
||||
it("starts local runtime manager when FUSION_DESKTOP_MODE is local", async () => {
|
||||
process.env.FUSION_DESKTOP_MODE = "local";
|
||||
const { initializeApp } = await import("../main.ts");
|
||||
await initializeApp();
|
||||
|
||||
expect(mocks.localServerManager.start).toHaveBeenCalled();
|
||||
expect(mocks.localRuntimeManager.startLocal).toHaveBeenCalled();
|
||||
delete process.env.FUSION_DESKTOP_MODE;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,10 +101,10 @@ vi.mock("electron", () => ({
|
||||
}));
|
||||
|
||||
const mainDeps = vi.hoisted(() => {
|
||||
const start = vi.fn(async () => undefined);
|
||||
const stop = vi.fn(async () => undefined);
|
||||
const getState = vi.fn(() => ({ status: "idle", error: null }));
|
||||
const getPort = vi.fn(() => 0);
|
||||
const startLocal = vi.fn(async () => ({ source: "embedded-local", state: "running", port: 4545 }));
|
||||
const stopLocal = vi.fn(async () => ({ source: "none", state: "stopped" }));
|
||||
const getStatus = vi.fn(() => ({ source: "none", state: "stopped" }));
|
||||
const getServerPort = vi.fn(() => 0);
|
||||
return {
|
||||
registerIpcHandlers: vi.fn(),
|
||||
buildAppMenu: vi.fn(),
|
||||
@@ -114,14 +114,8 @@ const mainDeps = vi.hoisted(() => {
|
||||
setupAutoUpdater: vi.fn(),
|
||||
loadWindowState: vi.fn(async () => null),
|
||||
saveWindowState: vi.fn(),
|
||||
readShellSettings: vi.fn(async () => ({
|
||||
desktopMode: null,
|
||||
hasCompletedModeSelection: false,
|
||||
activeProfileId: null,
|
||||
profiles: [],
|
||||
})),
|
||||
DesktopLocalServerManager: vi.fn(() => ({ start, stop, getState, getPort })),
|
||||
start,
|
||||
LocalRuntimeManager: vi.fn(() => ({ startLocal, stopLocal, getStatus, getServerPort })),
|
||||
startLocal,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -138,15 +132,8 @@ vi.mock("../native.js", () => ({
|
||||
saveWindowState: mainDeps.saveWindowState,
|
||||
setupAutoUpdater: mainDeps.setupAutoUpdater,
|
||||
}));
|
||||
vi.mock("../shell-settings.js", () => ({
|
||||
readShellSettings: mainDeps.readShellSettings,
|
||||
getDesktopShellModeState: (settings: { hasCompletedModeSelection: boolean; desktopMode: "local" | "remote" | null }) => ({
|
||||
isFirstRun: !settings.hasCompletedModeSelection || settings.desktopMode === null,
|
||||
desktopMode: settings.desktopMode,
|
||||
}),
|
||||
}));
|
||||
vi.mock("../local-server.js", () => ({
|
||||
DesktopLocalServerManager: mainDeps.DesktopLocalServerManager,
|
||||
vi.mock("../local-runtime.js", () => ({
|
||||
LocalRuntimeManager: mainDeps.LocalRuntimeManager,
|
||||
}));
|
||||
|
||||
async function importMainModule() {
|
||||
@@ -160,12 +147,7 @@ describe("main process", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
mainDeps.readShellSettings.mockResolvedValue({
|
||||
desktopMode: null,
|
||||
hasCompletedModeSelection: false,
|
||||
activeProfileId: null,
|
||||
profiles: [],
|
||||
});
|
||||
delete process.env.FUSION_DESKTOP_MODE;
|
||||
if (originalDashboardUrl === undefined) {
|
||||
delete process.env.FUSION_DASHBOARD_URL;
|
||||
} else {
|
||||
@@ -257,26 +239,21 @@ describe("main process", () => {
|
||||
expect(typeof mainModule.initializeApp).toBe("function");
|
||||
});
|
||||
|
||||
it("initializeApp starts local server only when persisted mode is local and not first run", async () => {
|
||||
mainDeps.readShellSettings.mockResolvedValueOnce({
|
||||
desktopMode: "local",
|
||||
hasCompletedModeSelection: true,
|
||||
activeProfileId: null,
|
||||
profiles: [],
|
||||
});
|
||||
it("initializeApp starts local runtime when FUSION_DESKTOP_MODE=local", async () => {
|
||||
process.env.FUSION_DESKTOP_MODE = "local";
|
||||
const { initializeApp } = await importMainModule();
|
||||
|
||||
await initializeApp();
|
||||
|
||||
expect(mainDeps.start).toHaveBeenCalledTimes(1);
|
||||
expect(mainDeps.startLocal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("initializeApp does not start local server on first run without mode selection", async () => {
|
||||
it("initializeApp does not start local runtime when FUSION_DESKTOP_MODE is unset", async () => {
|
||||
const { initializeApp } = await importMainModule();
|
||||
|
||||
await initializeApp();
|
||||
|
||||
expect(mainDeps.start).not.toHaveBeenCalled();
|
||||
expect(mainDeps.startLocal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("createMainWindow registers close and closed handlers", async () => {
|
||||
|
||||
Reference in New Issue
Block a user