feat(FN-773): add dedicated ScriptRunDialog for saved script output
- Add ScriptRunDialog component with xterm.js terminal emulation and real-time streaming - Align saved-script launches with backend contract (POST /api/scripts/:id/run) - Wire dialog into App.tsx with open/close state management - Add comprehensive styles for dialog overlay, terminal area, and action buttons - Add unit tests for ScriptRunDialog and App integration - Document ScriptRunDialog usage in dashboard README
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
import { ScriptRunDialog } from "../ScriptRunDialog";
|
||||
|
||||
// Mock the API
|
||||
vi.mock("../../api", () => ({
|
||||
killPtyTerminalSession: vi.fn(() => Promise.resolve({ killed: true })),
|
||||
}));
|
||||
|
||||
// Track useTerminal callback registrations
|
||||
const mockCallbacks = {
|
||||
data: [] as ((data: string) => void)[],
|
||||
scrollback: [] as ((data: string) => void)[],
|
||||
exit: [] as ((code: number) => void)[],
|
||||
connect: [] as ((info: { shell: string; cwd: string }) => void)[],
|
||||
};
|
||||
|
||||
const defaultUseTerminalReturn = {
|
||||
connectionStatus: "connected" as const,
|
||||
sendInput: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
onData: vi.fn((cb: (data: string) => void) => {
|
||||
mockCallbacks.data.push(cb);
|
||||
return () => {
|
||||
mockCallbacks.data = mockCallbacks.data.filter((c) => c !== cb);
|
||||
};
|
||||
}),
|
||||
onExit: vi.fn((cb: (code: number) => void) => {
|
||||
mockCallbacks.exit.push(cb);
|
||||
return () => {
|
||||
mockCallbacks.exit = mockCallbacks.exit.filter((c) => c !== cb);
|
||||
};
|
||||
}),
|
||||
onConnect: vi.fn((cb: (info: { shell: string; cwd: string }) => void) => {
|
||||
mockCallbacks.connect.push(cb);
|
||||
return () => {
|
||||
mockCallbacks.connect = mockCallbacks.connect.filter((c) => c !== cb);
|
||||
};
|
||||
}),
|
||||
onScrollback: vi.fn((cb: (data: string) => void) => {
|
||||
mockCallbacks.scrollback.push(cb);
|
||||
return () => {
|
||||
mockCallbacks.scrollback = mockCallbacks.scrollback.filter((c) => c !== cb);
|
||||
};
|
||||
}),
|
||||
reconnect: vi.fn(),
|
||||
};
|
||||
|
||||
const mockUseTerminal = vi.fn(() => ({ ...defaultUseTerminalReturn }));
|
||||
|
||||
vi.mock("../../hooks/useTerminal", () => ({
|
||||
useTerminal: (...args: unknown[]) => mockUseTerminal(...args),
|
||||
}));
|
||||
|
||||
const defaultProps = {
|
||||
isOpen: true as boolean,
|
||||
onClose: vi.fn(),
|
||||
scriptName: "build",
|
||||
sessionId: "sess-123",
|
||||
command: "npm run build",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCallbacks.data = [];
|
||||
mockCallbacks.scrollback = [];
|
||||
mockCallbacks.exit = [];
|
||||
mockCallbacks.connect = [];
|
||||
mockUseTerminal.mockClear();
|
||||
mockUseTerminal.mockReturnValue({ ...defaultUseTerminalReturn });
|
||||
});
|
||||
|
||||
describe("ScriptRunDialog", () => {
|
||||
it("renders with script name and command when open", () => {
|
||||
render(<ScriptRunDialog {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("script-run-dialog-overlay")).toBeTruthy();
|
||||
expect(screen.getByText("build")).toBeTruthy();
|
||||
expect(screen.getByTestId("script-run-command")).toBeTruthy();
|
||||
expect(screen.getByText("npm run build")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("returns null when not open", () => {
|
||||
render(<ScriptRunDialog {...defaultProps} isOpen={false} />);
|
||||
|
||||
expect(screen.queryByTestId("script-run-dialog-overlay")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows loading state while waiting for output", () => {
|
||||
render(<ScriptRunDialog {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("Waiting for output...")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders live output as it arrives", async () => {
|
||||
render(<ScriptRunDialog {...defaultProps} />);
|
||||
|
||||
// Simulate data arriving
|
||||
await act(async () => {
|
||||
mockCallbacks.data.forEach((cb) => cb("Building project...\n"));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Building project\.\.\./)).toBeTruthy();
|
||||
});
|
||||
|
||||
// Simulate more data
|
||||
await act(async () => {
|
||||
mockCallbacks.data.forEach((cb) => cb("Done!\n"));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Done!/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows completed status on exit code 0", async () => {
|
||||
render(<ScriptRunDialog {...defaultProps} />);
|
||||
|
||||
await act(async () => {
|
||||
mockCallbacks.exit.forEach((cb) => cb(0));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Completed")).toBeTruthy();
|
||||
expect(screen.getByTestId("script-run-exit-code")).toBeTruthy();
|
||||
expect(screen.getByText("Exit code: 0")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error status on non-zero exit code", async () => {
|
||||
render(<ScriptRunDialog {...defaultProps} />);
|
||||
|
||||
await act(async () => {
|
||||
mockCallbacks.exit.forEach((cb) => cb(1));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Failed \(exit code 1\)/)).toBeTruthy();
|
||||
expect(screen.getByText("Exit code: 1")).toBeTruthy();
|
||||
const exitCodeEl = screen.getByTestId("script-run-exit-code");
|
||||
expect(exitCodeEl.classList.contains("error")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("calls killPtyTerminalSession when closed while running", async () => {
|
||||
const onClose = vi.fn();
|
||||
const { killPtyTerminalSession } = await import("../../api");
|
||||
|
||||
render(<ScriptRunDialog {...defaultProps} onClose={onClose} />);
|
||||
|
||||
// Click close button while still running
|
||||
fireEvent.click(screen.getByTestId("script-run-dialog-close"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(killPtyTerminalSession).toHaveBeenCalledWith("sess-123");
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not kill session when closed after completion", async () => {
|
||||
const onClose = vi.fn();
|
||||
const { killPtyTerminalSession } = await import("../../api");
|
||||
|
||||
render(<ScriptRunDialog {...defaultProps} onClose={onClose} />);
|
||||
|
||||
// Process exits successfully
|
||||
await act(async () => {
|
||||
mockCallbacks.exit.forEach((cb) => cb(0));
|
||||
});
|
||||
|
||||
// Click close after completion
|
||||
fireEvent.click(screen.getByTestId("script-run-dialog-close"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(killPtyTerminalSession).not.toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("closes on Escape key", async () => {
|
||||
const onClose = vi.fn();
|
||||
|
||||
render(<ScriptRunDialog {...defaultProps} onClose={onClose} />);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
});
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes on overlay click", async () => {
|
||||
const onClose = vi.fn();
|
||||
|
||||
render(<ScriptRunDialog {...defaultProps} onClose={onClose} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("script-run-dialog-overlay"));
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows connecting status when not yet connected", async () => {
|
||||
mockUseTerminal.mockReturnValue({
|
||||
...defaultUseTerminalReturn,
|
||||
connectionStatus: "connecting",
|
||||
});
|
||||
|
||||
render(<ScriptRunDialog {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("Connecting...")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user