Files
fusion/packages/dashboard/app/hooks/__tests__/useEngineStatus.test.ts
gsxdsm e46ea00ff5 FN-6827: add engine status banner
Add project-scoped engine connectivity remediation to the dashboard.

- Add engine status/start API helpers and server routes that can resume paused projects or start missing engines.
- Render a project banner with dashboard-only guidance, polling, disabled starting state, localized copy, and focused tests.
- Document the engine status banner behavior and add a changeset for the published CLI package.

Files changed:
 .changeset/fn-6827-engine-disconnected-banner.md   |   7 +
 docs/dashboard-guide.md                            |   8 +
 packages/dashboard/app/api/legacy.ts               |  20 +++
 .../app/components/EngineStatusBanner.css          |  87 +++++++++++
 .../app/components/EngineStatusBanner.tsx          |  62 ++++++++
 .../__tests__/EngineStatusBanner.test.tsx          | 145 +++++++++++++++++
 .../app/components/dashboard/DashboardBanners.tsx  |   3 +
 .../app/hooks/__tests__/useEngineStatus.test.ts    | 171 +++++++++++++++++++++
 packages/dashboard/app/hooks/useEngineStatus.ts    | 111 +++++++++++++
 packages/dashboard/src/__tests__/server.test.ts    | 129 ++++++++++++++++
 packages/dashboard/src/server.ts                   |  89 +++++++++++
 packages/i18n/locales/en/app.json                  |  10 ++
 packages/i18n/locales/es/app.json                  |  10 ++
 packages/i18n/locales/fr/app.json                  |  10 ++
 packages/i18n/locales/ko/app.json                  |  10 ++
 packages/i18n/locales/zh-CN/app.json               |  10 ++
 packages/i18n/locales/zh-TW/app.json               |  10 ++
 17 files changed, 892 insertions(+)

Fusion-Task-Id: FN-6827

Fusion-Task-Lineage: 56821ca3-04c8-4674-9400-3639f10e463d
2026-06-25 08:35:44 -07:00

172 lines
6.2 KiB
TypeScript

import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useEngineStatus } from "../useEngineStatus";
import * as api from "../../api";
vi.mock("../../api", () => ({
fetchEngineStatus: vi.fn(),
startEngine: vi.fn(),
}));
const mockFetchEngineStatus = vi.mocked(api.fetchEngineStatus);
const mockStartEngine = vi.mocked(api.startEngine);
async function flushPromises(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
describe("useEngineStatus", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
mockFetchEngineStatus.mockReset();
mockStartEngine.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it("fetches project-scoped engine status on mount", async () => {
mockFetchEngineStatus.mockResolvedValueOnce({ connected: false, starting: false, canStart: true, projectId: "project-a" });
const { result } = renderHook(() => useEngineStatus("project-a"));
await act(async () => {
await flushPromises();
});
expect(mockFetchEngineStatus).toHaveBeenCalledWith("project-a");
expect(result.current.loading).toBe(false);
expect(result.current.status).toEqual({ connected: false, starting: false, canStart: true, projectId: "project-a" });
expect(result.current.canStart).toBe(true);
expect(result.current.starting).toBe(false);
});
it("does not fetch or render stale status when no project is selected", async () => {
const { result } = renderHook(() => useEngineStatus(undefined));
await act(async () => {
await flushPromises();
await vi.advanceTimersByTimeAsync(10000);
});
expect(mockFetchEngineStatus).not.toHaveBeenCalled();
expect(result.current.loading).toBe(false);
expect(result.current.status).toBeNull();
expect(result.current.canStart).toBe(false);
});
it("polls while disconnected and pauses polling once connected", async () => {
mockFetchEngineStatus
.mockResolvedValueOnce({ connected: false, starting: false, canStart: true, projectId: "project-a" })
.mockResolvedValueOnce({ connected: true, starting: false, canStart: true, projectId: "project-a" });
const { result } = renderHook(() => useEngineStatus("project-a"));
await act(async () => {
await flushPromises();
});
expect(result.current.status?.connected).toBe(false);
await act(async () => {
await vi.advanceTimersByTimeAsync(10000);
await flushPromises();
});
expect(result.current.status?.connected).toBe(true);
await act(async () => {
await vi.advanceTimersByTimeAsync(30000);
await flushPromises();
});
expect(mockFetchEngineStatus).toHaveBeenCalledTimes(2);
});
it("treats status fetch failures as disconnected without enabling start", async () => {
mockFetchEngineStatus.mockRejectedValueOnce(new Error("network down"));
const { result } = renderHook(() => useEngineStatus("project-a"));
await act(async () => {
await flushPromises();
});
expect(result.current.status).toEqual({ connected: false, starting: false, canStart: false, reason: "unreachable", projectId: "project-a" });
expect(result.current.canStart).toBe(false);
expect(result.current.error).toBeNull();
});
it("starts the engine, exposes local in-flight state, and immediately refetches", async () => {
mockFetchEngineStatus
.mockResolvedValueOnce({ connected: false, starting: false, canStart: true, projectId: "project-a" })
.mockResolvedValueOnce({ connected: true, starting: false, canStart: true, projectId: "project-a" });
let resolveStart!: (status: { connected: boolean; starting: boolean; canStart: boolean; projectId: string }) => void;
mockStartEngine.mockReturnValueOnce(new Promise((resolve) => {
resolveStart = resolve;
}));
const { result } = renderHook(() => useEngineStatus("project-a"));
await act(async () => {
await flushPromises();
});
let startPromise!: Promise<void>;
await act(async () => {
startPromise = result.current.start();
await flushPromises();
});
expect(result.current.starting).toBe(true);
await act(async () => {
resolveStart({ connected: false, starting: true, canStart: true, projectId: "project-a" });
await startPromise;
await flushPromises();
});
expect(mockStartEngine).toHaveBeenCalledWith("project-a");
expect(mockFetchEngineStatus).toHaveBeenCalledTimes(2);
expect(result.current.status).toEqual({ connected: true, starting: false, canStart: true, projectId: "project-a" });
expect(result.current.starting).toBe(false);
});
it("surfaces start failures without throwing away the disconnected status", async () => {
mockFetchEngineStatus.mockResolvedValueOnce({ connected: false, starting: false, canStart: true, projectId: "project-a" });
mockStartEngine.mockRejectedValueOnce(new Error("start failed"));
const { result } = renderHook(() => useEngineStatus("project-a"));
await act(async () => {
await flushPromises();
});
await act(async () => {
await result.current.start();
});
expect(result.current.error).toBe("start failed");
expect(result.current.status).toEqual({ connected: false, starting: false, canStart: true, projectId: "project-a" });
expect(result.current.starting).toBe(false);
});
it("immediately refetches when the project changes", async () => {
mockFetchEngineStatus
.mockResolvedValueOnce({ connected: false, starting: false, canStart: true, projectId: "project-a" })
.mockResolvedValueOnce({ connected: true, starting: false, canStart: true, projectId: "project-b" });
const { result, rerender } = renderHook(({ projectId }) => useEngineStatus(projectId), {
initialProps: { projectId: "project-a" },
});
await act(async () => {
await flushPromises();
});
expect(result.current.status?.projectId).toBe("project-a");
rerender({ projectId: "project-b" });
await act(async () => {
await flushPromises();
});
expect(mockFetchEngineStatus).toHaveBeenNthCalledWith(2, "project-b");
expect(result.current.status).toEqual({ connected: true, starting: false, canStart: true, projectId: "project-b" });
});
});