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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user