diff --git a/.changeset/fn-7476-desktop-engine-banner.md b/.changeset/fn-7476-desktop-engine-banner.md new file mode 100644 index 0000000000..5b29ed00c6 --- /dev/null +++ b/.changeset/fn-7476-desktop-engine-banner.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix a false AI engine not running banner in desktop mode. +category: fix +dev: Distinguishes transient embedded desktop engine startup from true dashboard-only mode. diff --git a/packages/dashboard/app/components/dashboard/__tests__/DashboardBanners.test.tsx b/packages/dashboard/app/components/dashboard/__tests__/DashboardBanners.test.tsx index 004b588219..5918a9396c 100644 --- a/packages/dashboard/app/components/dashboard/__tests__/DashboardBanners.test.tsx +++ b/packages/dashboard/app/components/dashboard/__tests__/DashboardBanners.test.tsx @@ -331,6 +331,24 @@ describe("DashboardBanners engine remediation visibility", () => { expect(screen.getAllByRole("button", { name: /start engine/i })).toHaveLength(2); }); + it("does not mount the dashboard-only unavailable banner when desktop health reports a startable manager", () => { + render( + , + ); + + expect(screen.queryByTestId("engine-unavailable-banner")).not.toBeInTheDocument(); + expect(screen.getByTestId("engine-status-banner")).toBeInTheDocument(); + expect(screen.getAllByRole("button", { name: /start engine/i })).toHaveLength(1); + }); + it("preserves the current project guard while auth recovery is closed", () => { render(); diff --git a/packages/dashboard/app/hooks/__tests__/useDashboardHealth.test.ts b/packages/dashboard/app/hooks/__tests__/useDashboardHealth.test.ts index e865938de9..d6daf0cbf3 100644 --- a/packages/dashboard/app/hooks/__tests__/useDashboardHealth.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useDashboardHealth.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { renderHook, act, waitFor } from "@testing-library/react"; const fetchDashboardHealth = vi.fn(); @@ -16,6 +16,10 @@ describe("useDashboardHealth", () => { refreshDashboardHealth.mockReset(); }); + afterEach(() => { + vi.useRealTimers(); + }); + it("seeds health from the mount fetch and falls back to null on failure", async () => { fetchDashboardHealth.mockResolvedValue({ status: "ok" }); const { result } = renderHook(() => useDashboardHealth()); @@ -59,6 +63,24 @@ describe("useDashboardHealth", () => { expect(result.current.refreshError).toBe("nope"); expect(result.current.refreshing).toBe(false); }); + + it("polls health so a transient unavailable desktop startup response is replaced", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + fetchDashboardHealth + .mockResolvedValueOnce({ status: "ok", engine: { available: false } }) + .mockResolvedValueOnce({ status: "ok", engine: { available: true } }); + + const { result } = renderHook(() => useDashboardHealth()); + + await waitFor(() => expect(result.current.health).toEqual({ status: "ok", engine: { available: false } })); + + await act(async () => { + await vi.advanceTimersByTimeAsync(15_000); + }); + + await waitFor(() => expect(result.current.health).toEqual({ status: "ok", engine: { available: true } })); + expect(fetchDashboardHealth).toHaveBeenCalledTimes(2); + }); it("fires the mount fetch and tolerates an unmount before it resolves", async () => { let resolveMount: (value: { status: string }) => void = () => {}; fetchDashboardHealth.mockImplementation( diff --git a/packages/dashboard/src/__tests__/server.test.ts b/packages/dashboard/src/__tests__/server.test.ts index a01cc04e0f..c7c966da73 100644 --- a/packages/dashboard/src/__tests__/server.test.ts +++ b/packages/dashboard/src/__tests__/server.test.ts @@ -462,14 +462,9 @@ describe("createServer health and headless mode", () => { }); }); - it("reports the engine unavailable when the manager has no running engines", async () => { + it("reports the engine unavailable when no engine manager can run automation", async () => { const store = createMockStore(); - const app = createServer(store, { - engineManager: { - getAllEngines: vi.fn().mockReturnValue(new Map()), - getEngine: vi.fn(), - } as any, - }); + const app = createServer(store); const res = await GET(app, "/api/health"); @@ -477,6 +472,27 @@ describe("createServer health and headless mode", () => { expect(res.body.engine).toEqual({ available: false }); }); + it("keeps desktop embedded managers available while engines are still starting or lazily startable", async () => { + const store = createMockStore(); + const app = createServer(store, { + engineManager: { + getAllEngines: vi.fn().mockReturnValue(new Map()), + hasRunningEngine: vi.fn().mockReturnValue(false), + getEngine: vi.fn(), + has: vi.fn((projectId: string) => projectId === "starting"), + } as any, + }); + + const health = await GET(app, "/api/health"); + const startingStatus = await GET(app, "/api/engine/status?projectId=starting"); + const lazilyStartableStatus = await GET(app, "/api/engine/status?projectId=missing"); + + expect(health.status).toBe(200); + expect(health.body.engine).toEqual({ available: true }); + expect(startingStatus.body).toEqual({ connected: false, starting: true, canStart: true, projectId: "starting" }); + expect(lazilyStartableStatus.body).toEqual({ connected: false, starting: false, canStart: true, projectId: "missing" }); + }); + it("reports the engine available when the manager has a running engine", async () => { const store = createMockStore(); const engine = { diff --git a/packages/dashboard/src/server.ts b/packages/dashboard/src/server.ts index 9652202869..3ed2acf858 100644 --- a/packages/dashboard/src/server.ts +++ b/packages/dashboard/src/server.ts @@ -501,11 +501,14 @@ function hasDashboardEngine(options?: ServerOptions): boolean { * by this process AND engines owned by another fusion process on the machine * (detected via the singleton lock); without the latter a UI-only launch * alongside an already-running engine shows a false "engine not running" - * banner. Fall back to the owned-engine map for older manager instances / - * test doubles. + * banner. + * + * FNXC:DesktopEngineAvailability 2026-07-03-16:15: + * Desktop embedded local mode creates modern ProjectEngineManager before any project engine may exist. + * Treating an empty owned-engine map as dashboard-only makes the app shell show restart-Fusion remediation while the same manager is still starting, reconciling, or able to lazily ensure the selected project engine. A manager with machine-level liveness support therefore means automation is available at the process level; project-scoped disconnected/starting details stay on /api/engine/status. Older manager test doubles without that method retain the historical owned-engine fallback. */ if (typeof manager.hasRunningEngine === "function") { - return manager.hasRunningEngine(); + return true; } const engines = manager.getAllEngines?.(); return Boolean(engines && engines.size > 0);