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:
gsxdsm
2026-04-03 10:31:30 -07:00
parent 2b5ae95fde
commit 41adcda408
7 changed files with 663 additions and 10 deletions

View File

@@ -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();
});
});