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
146 lines
5.8 KiB
TypeScript
146 lines
5.8 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { EngineStatusBanner } from "../EngineStatusBanner";
|
|
import * as api from "../../api";
|
|
|
|
vi.mock("react-i18next", () => ({
|
|
useTranslation: () => ({
|
|
t: (_key: string, fallback?: string, values?: Record<string, string>) => {
|
|
let text = fallback ?? _key;
|
|
if (values) {
|
|
for (const [key, value] of Object.entries(values)) {
|
|
text = text.replace(`{{${key}}}`, value);
|
|
}
|
|
}
|
|
return text;
|
|
},
|
|
}),
|
|
}));
|
|
|
|
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("EngineStatusBanner", () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
mockFetchEngineStatus.mockReset();
|
|
mockStartEngine.mockReset();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("renders no banner, button, or aria-live shell when the engine is connected", async () => {
|
|
mockFetchEngineStatus.mockResolvedValueOnce({ connected: true, starting: false, canStart: true, projectId: "project-a" });
|
|
|
|
const { queryByTestId, container } = render(<EngineStatusBanner projectId="project-a" />);
|
|
await act(async () => {
|
|
await flushPromises();
|
|
});
|
|
|
|
expect(queryByTestId("engine-status-banner")).toBeNull();
|
|
expect(queryByTestId("engine-status-start-button")).toBeNull();
|
|
expect(container.querySelector('[aria-live="polite"]')).toBeNull();
|
|
});
|
|
|
|
it("shows an enabled Start engine button when disconnected and startable", async () => {
|
|
mockFetchEngineStatus.mockResolvedValueOnce({ connected: false, starting: false, canStart: true, projectId: "project-a" });
|
|
|
|
render(<EngineStatusBanner projectId="project-a" />);
|
|
|
|
expect(await screen.findByTestId("engine-status-banner")).toBeInTheDocument();
|
|
const button = screen.getByTestId("engine-status-start-button");
|
|
expect(button).toBeEnabled();
|
|
expect(button).toHaveTextContent("Start engine");
|
|
expect(screen.getByText("Project engine is not connected")).toBeInTheDocument();
|
|
});
|
|
|
|
it("clicking Start engine calls startEngine and refetches status", async () => {
|
|
mockFetchEngineStatus
|
|
.mockResolvedValueOnce({ connected: false, starting: false, canStart: true, projectId: "project-a" })
|
|
.mockResolvedValueOnce({ connected: true, starting: false, canStart: true, projectId: "project-a" });
|
|
mockStartEngine.mockResolvedValueOnce({ connected: false, starting: true, canStart: true, projectId: "project-a" });
|
|
|
|
const { queryByTestId, container } = render(<EngineStatusBanner projectId="project-a" />);
|
|
const button = await screen.findByTestId("engine-status-start-button");
|
|
|
|
await act(async () => {
|
|
fireEvent.click(button);
|
|
await flushPromises();
|
|
});
|
|
|
|
expect(mockStartEngine).toHaveBeenCalledWith("project-a");
|
|
expect(mockFetchEngineStatus).toHaveBeenCalledTimes(2);
|
|
await waitFor(() => expect(queryByTestId("engine-status-banner")).toBeNull());
|
|
expect(queryByTestId("engine-status-start-button")).toBeNull();
|
|
expect(container.querySelector('[aria-live="polite"]')).toBeNull();
|
|
});
|
|
|
|
it("disables the Start engine button while the engine is starting", async () => {
|
|
mockFetchEngineStatus.mockResolvedValueOnce({ connected: false, starting: true, canStart: true, projectId: "project-a" });
|
|
|
|
render(<EngineStatusBanner projectId="project-a" />);
|
|
|
|
const button = await screen.findByTestId("engine-status-start-button");
|
|
expect(button).toBeDisabled();
|
|
expect(button).toHaveTextContent("Starting…");
|
|
});
|
|
|
|
it("shows dashboard-only guidance with no start button when the server cannot start engines", async () => {
|
|
mockFetchEngineStatus.mockResolvedValueOnce({ connected: false, starting: false, canStart: false, reason: "dashboard-only", projectId: "project-a" });
|
|
|
|
render(<EngineStatusBanner projectId="project-a" />);
|
|
|
|
expect(await screen.findByTestId("engine-status-banner")).toBeInTheDocument();
|
|
expect(screen.queryByTestId("engine-status-start-button")).toBeNull();
|
|
expect(screen.getByText("fn serve")).toBeInTheDocument();
|
|
});
|
|
|
|
it("treats unreachable status probes as disconnected guidance without an enabled action", async () => {
|
|
mockFetchEngineStatus.mockRejectedValueOnce(new Error("offline"));
|
|
|
|
render(<EngineStatusBanner projectId="project-a" />);
|
|
|
|
expect(await screen.findByTestId("engine-status-banner")).toBeInTheDocument();
|
|
expect(screen.queryByTestId("engine-status-start-button")).toBeNull();
|
|
expect(screen.getByText("fn serve")).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders retryable start errors inline", async () => {
|
|
mockFetchEngineStatus.mockResolvedValueOnce({ connected: false, starting: false, canStart: true, projectId: "project-a" });
|
|
mockStartEngine.mockRejectedValueOnce(new Error("engine failed"));
|
|
|
|
render(<EngineStatusBanner projectId="project-a" />);
|
|
const button = await screen.findByTestId("engine-status-start-button");
|
|
|
|
await act(async () => {
|
|
fireEvent.click(button);
|
|
await flushPromises();
|
|
});
|
|
|
|
expect(screen.getByRole("alert")).toHaveTextContent("Start failed: engine failed");
|
|
expect(screen.getByTestId("engine-status-start-button")).toBeEnabled();
|
|
});
|
|
|
|
it("ships responsive mobile scaffolding for the banner stack", () => {
|
|
const css = readFileSync(resolve(__dirname, "..", "EngineStatusBanner.css"), "utf8");
|
|
|
|
expect(css).toContain("@media (max-width: 768px)");
|
|
expect(css).toContain(".engine-status-banner__start");
|
|
expect(css).toContain("width: 100%");
|
|
});
|
|
});
|