feat(FN-2170): add dev-server preview detection and log viewer flow

- Add dev-server port detection utilities and wire detected preview state into process/manager lifecycle
- Normalize dev-server status API responses with preview URL, detected port, and manual override compatibility
- Introduce useDevServerLogs and DevServerLogViewer, and integrate them into the DevServer dashboard view
- Expand dashboard test coverage for detection edges, process/routes behavior, log history handling, and CSS regressions
This commit is contained in:
Fusion
2026-04-20 15:37:32 -07:00
committed by gsxdsm
parent 6522369168
commit c8203210c2
17 changed files with 3585 additions and 1111 deletions

View File

@@ -1,68 +1,117 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DevServerConfig, DevServerState } from "../../api";
import { DevServerView } from "../DevServerView";
const mockUseDevServer = vi.fn();
const mockUseDevServerConfig = vi.fn();
const mockUseDevServerLogs = vi.fn();
vi.mock("../../hooks/useDevServer", () => ({
useDevServer: (...args: unknown[]) => mockUseDevServer(...args),
}));
vi.mock("../../hooks/useDevServerConfig", () => ({
useDevServerConfig: (...args: unknown[]) => mockUseDevServerConfig(...args),
}));
vi.mock("../../hooks/useDevServerLogs", () => ({
useDevServerLogs: (...args: unknown[]) => mockUseDevServerLogs(...args),
}));
vi.mock("lucide-react", () => ({
AlertCircle: () => <span data-testid="icon-alert-circle" />,
ChevronLeft: () => <span data-testid="icon-chevron-left" />,
ChevronRight: () => <span data-testid="icon-chevron-right" />,
Copy: () => <span data-testid="icon-copy" />,
ChevronDown: () => <span data-testid="icon-chevron-down" />,
ExternalLink: () => <span data-testid="icon-external-link" />,
Loader2: () => <span data-testid="icon-loader" />,
Maximize2: () => <span data-testid="icon-maximize" />,
Minimize2: () => <span data-testid="icon-minimize" />,
Monitor: () => <span data-testid="icon-monitor" />,
Play: () => <span data-testid="icon-play" />,
RotateCw: () => <span data-testid="icon-rotate" />,
Search: () => <span data-testid="icon-search" />,
Server: () => <span data-testid="icon-server" />,
Square: () => <span data-testid="icon-square" />,
Terminal: () => <span data-testid="icon-terminal" />,
}));
function createHookState(overrides: Record<string, unknown> = {}) {
function createState(overrides: Partial<DevServerState> = {}): DevServerState {
return {
id: "default",
name: "default",
status: "stopped",
logs: ["ready", "[stderr] warning"],
detectedUrl: "http://localhost:5173",
manualUrl: null,
selectedCommand: "pnpm dev",
command: "pnpm dev",
scriptName: "dev",
cwd: ".",
logs: [],
...overrides,
};
}
function createDevServerHookState(overrides: Record<string, unknown> = {}) {
return {
candidates: [
{
scriptName: "dev",
command: "pnpm dev",
packagePath: ".",
confidence: 1,
name: "dev",
command: "pnpm dev",
scriptName: "dev",
cwd: ".",
source: "root",
label: "project · dev (root)",
},
{
scriptName: "start",
command: "pnpm --filter web start",
packagePath: "apps/web",
confidence: 0.9,
name: "start",
command: "pnpm start --filter web",
scriptName: "start",
cwd: "apps/web",
source: "apps/web",
label: "web · start (apps/web)",
workspaceName: "@demo/web",
label: "@demo/web · start (apps/web)",
},
],
isLoading: false,
error: null,
serverState: createState(),
logs: ["ready"],
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
restart: vi.fn().mockResolvedValue(undefined),
setManualUrl: vi.fn().mockResolvedValue(undefined),
detect: vi.fn().mockResolvedValue(undefined),
refreshStatus: vi.fn().mockResolvedValue(undefined),
serverState: null,
loading: false,
setPreviewUrl: vi.fn().mockResolvedValue(undefined),
loading: false,
error: null,
...overrides,
};
}
function createConfig(overrides: Partial<DevServerConfig> = {}): DevServerConfig {
return {
selectedScript: null,
selectedSource: null,
selectedCommand: null,
previewUrlOverride: null,
detectedPreviewUrl: null,
selectedAt: null,
...overrides,
};
}
function createConfigHookState(overrides: Record<string, unknown> = {}) {
return {
config: createConfig(),
loading: false,
error: null,
selectScript: vi.fn().mockResolvedValue(undefined),
clearSelection: vi.fn().mockResolvedValue(undefined),
setPreviewUrlOverride: vi.fn().mockResolvedValue(undefined),
refresh: vi.fn().mockResolvedValue(undefined),
...overrides,
};
}
function createDevServerLogsHookState(overrides: Record<string, unknown> = {}) {
return {
entries: [],
loading: false,
loadingMore: false,
hasMore: false,
total: 0,
loadMore: vi.fn(),
clear: vi.fn(),
...overrides,
};
}
@@ -72,126 +121,96 @@ describe("DevServerView", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseDevServer.mockReturnValue(createHookState());
mockUseDevServer.mockReturnValue(createDevServerHookState());
mockUseDevServerConfig.mockReturnValue(createConfigHookState());
mockUseDevServerLogs.mockReturnValue(createDevServerLogsHookState());
});
it("renders command detection panel with candidates", () => {
render(<DevServerView projectId="project-a" addToast={addToast} />);
it("renders candidate list when detection returns scripts", () => {
render(<DevServerView addToast={addToast} projectId="project-a" />);
expect(screen.getByTestId("devserver-candidates")).toBeInTheDocument();
expect(screen.getByText("pnpm dev")).toBeInTheDocument();
expect(screen.getByText("pnpm --filter web start")).toBeInTheDocument();
expect(screen.getByTestId("dev-server-candidates")).toBeInTheDocument();
expect(screen.getByText("dev")).toBeInTheDocument();
expect(screen.getByText("start")).toBeInTheDocument();
});
it("renders process controls with start/stop/restart buttons", () => {
render(<DevServerView projectId="project-a" addToast={addToast} />);
it("renders no-candidates message when candidates are empty", () => {
mockUseDevServer.mockReturnValue(createDevServerHookState({ candidates: [] }));
expect(screen.getByTestId("devserver-start-button")).toBeInTheDocument();
expect(screen.getByTestId("devserver-stop-button")).toBeInTheDocument();
expect(screen.getByTestId("devserver-restart-button")).toBeInTheDocument();
render(<DevServerView addToast={addToast} projectId="project-a" />);
expect(screen.getByTestId("dev-server-empty-candidates")).toHaveTextContent("No dev server scripts detected");
});
it("disables start button when status is running", () => {
mockUseDevServer.mockReturnValue(createHookState({ status: "running" }));
it("clicking a candidate persists selection via selectScript", async () => {
const selectScript = vi.fn().mockResolvedValue(undefined);
mockUseDevServerConfig.mockReturnValue(createConfigHookState({ selectScript }));
render(<DevServerView projectId="project-a" addToast={addToast} />);
render(<DevServerView addToast={addToast} projectId="project-a" />);
expect(screen.getByTestId("devserver-start-button")).toBeDisabled();
});
it("disables stop button when status is stopped", () => {
mockUseDevServer.mockReturnValue(createHookState({ status: "stopped" }));
render(<DevServerView projectId="project-a" addToast={addToast} />);
expect(screen.getByTestId("devserver-stop-button")).toBeDisabled();
});
it("clicking start calls hook start()", async () => {
const start = vi.fn().mockResolvedValue(undefined);
mockUseDevServer.mockReturnValue(createHookState({ start }));
render(<DevServerView projectId="project-a" addToast={addToast} />);
fireEvent.click(screen.getByTestId("devserver-start-button"));
fireEvent.click(screen.getByTestId("dev-server-candidate-dev-root"));
await waitFor(() => {
expect(start).toHaveBeenCalled();
});
expect(start.mock.calls[0]?.[0]).toBe("pnpm dev");
});
it("clicking stop calls hook stop()", async () => {
const stop = vi.fn().mockResolvedValue(undefined);
mockUseDevServer.mockReturnValue(createHookState({ status: "running", stop }));
render(<DevServerView projectId="project-a" addToast={addToast} />);
fireEvent.click(screen.getByTestId("devserver-stop-button"));
await waitFor(() => {
expect(stop).toHaveBeenCalled();
expect(selectScript).toHaveBeenCalledWith({
name: "dev",
command: "pnpm dev",
source: "root",
});
});
});
it("renders log viewer with log lines", () => {
render(<DevServerView projectId="project-a" addToast={addToast} />);
it("highlights the selected candidate", () => {
mockUseDevServerConfig.mockReturnValue(
createConfigHookState({
config: createConfig({
selectedScript: "dev",
selectedSource: "root",
selectedCommand: "pnpm dev",
}),
}),
);
expect(screen.getByTestId("devserver-log-viewer")).toBeInTheDocument();
expect(screen.getByText("ready")).toBeInTheDocument();
expect(screen.getByText("[stderr] warning")).toBeInTheDocument();
render(<DevServerView addToast={addToast} projectId="project-a" />);
fireEvent.click(screen.getByTestId("dev-server-change-selection"));
const selected = screen.getByTestId("dev-server-candidate-dev-root");
expect(selected).toHaveClass("dev-server-candidate--selected");
});
it("renders preview panel with detected URL", () => {
render(<DevServerView projectId="project-a" addToast={addToast} />);
it("saves preview URL override from input", async () => {
const setPreviewUrlOverride = vi.fn().mockResolvedValue(undefined);
const setPreviewUrl = vi.fn().mockResolvedValue(undefined);
expect(screen.getByTestId("devserver-effective-url")).toHaveTextContent("http://localhost:5173");
});
mockUseDevServerConfig.mockReturnValue(createConfigHookState({ setPreviewUrlOverride }));
mockUseDevServer.mockReturnValue(createDevServerHookState({ setPreviewUrl }));
it("open in new tab button opens preview URL", () => {
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
render(<DevServerView addToast={addToast} projectId="project-a" />);
render(<DevServerView projectId="project-a" addToast={addToast} />);
fireEvent.click(screen.getByTestId("devserver-open-tab-button"));
expect(openSpy).toHaveBeenCalledWith("http://localhost:5173", "_blank", "noopener,noreferrer");
});
it("shows iframe fallback message when iframe errors", async () => {
render(<DevServerView projectId="project-a" addToast={addToast} />);
const iframe = screen.getByTestId("devserver-preview-iframe");
fireEvent(iframe, new Event("error"));
await waitFor(() => {
expect(screen.getByText(/Preview cannot be embedded due to security restrictions/i)).toBeInTheDocument();
});
});
it("manual url input calls setManualUrl()", async () => {
const setManualUrl = vi.fn().mockResolvedValue(undefined);
mockUseDevServer.mockReturnValue(createHookState({ setManualUrl }));
render(<DevServerView projectId="project-a" addToast={addToast} />);
fireEvent.change(screen.getByTestId("devserver-manual-url-input"), {
fireEvent.change(screen.getByTestId("dev-server-preview-input"), {
target: { value: "http://localhost:3000" },
});
fireEvent.click(screen.getByTestId("devserver-set-url-button"));
fireEvent.click(screen.getByTestId("dev-server-set-preview"));
await waitFor(() => {
expect(setManualUrl).toHaveBeenCalledWith("http://localhost:3000");
expect(setPreviewUrlOverride).toHaveBeenCalledWith("http://localhost:3000");
expect(setPreviewUrl).toHaveBeenCalledWith("http://localhost:3000");
});
});
it("status indicator uses class based on status", () => {
mockUseDevServer.mockReturnValue(createHookState({ status: "failed" }));
it("renders selected script summary when config has a selection", () => {
mockUseDevServerConfig.mockReturnValue(
createConfigHookState({
config: createConfig({
selectedScript: "dev",
selectedSource: "root",
selectedCommand: "pnpm dev",
}),
}),
);
render(<DevServerView projectId="project-a" addToast={addToast} />);
render(<DevServerView addToast={addToast} projectId="project-a" />);
expect(screen.getByTestId("devserver-status-dot")).toHaveClass("devserver-status-dot--failed");
expect(screen.getByTestId("dev-server-selected-summary")).toBeInTheDocument();
});
});