FN-7476: fix desktop engine availability banner
Treat desktop embedded engine managers as automation-capable during startup so the dashboard avoids false remediation. - Report process-level engine availability when a modern manager can lazily start or is starting project engines.\n- Keep project-scoped status details on /api/engine/status while hiding the dashboard-only unavailable banner in desktop mode.\n- Add dashboard health polling, banner, server health, and changeset coverage for the desktop false-banner regression.\n\nFiles changed:\n .changeset/fn-7476-desktop-engine-banner.md | 7 +++++++\n .../dashboard/__tests__/DashboardBanners.test.tsx | 18 ++++++++++++++++\n .../app/hooks/__tests__/useDashboardHealth.test.ts | 24 +++++++++++++++++++++-\n packages/dashboard/src/__tests__/server.test.ts | 24 ++++++++++++++++++----\n packages/dashboard/src/server.ts | 9 +++++---\n 5 files changed, 74 insertions(+), 8 deletions(-) Fusion-Task-Id: FN-7476 Fusion-Task-Lineage: d54a9872-252b-44c5-9c6d-6168b65118c7 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7476-desktop-engine-banner.md
Normal file
7
.changeset/fn-7476-desktop-engine-banner.md
Normal file
@@ -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.
|
||||
@@ -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(
|
||||
<DashboardBanners
|
||||
{...buildProps({
|
||||
authTokenRecoveryOpen: false,
|
||||
dashboardHealth: {
|
||||
...unavailableEngineHealth(),
|
||||
engine: { available: true },
|
||||
} as DashboardBannersProps["dashboardHealth"],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(<DashboardBanners {...buildProps({ authTokenRecoveryOpen: false, currentProject: null })} />);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user