fix(FN-868): add terminal bootstrap error handling with retry and improve workflow step manager
- Add bootstrap error state and retry logic to useTerminalSessions hook to prevent indefinite 'Starting terminal' hang - Add error/retry UI states in TerminalModal component with CSS styling - Add tests for useTerminalSessions bootstrap error and retry behavior - Refactor WorkflowStepManager with template support and improved UX - Extract CSS from inline styles to stylesheet for terminal and workflow components - Document terminal startup error handling and non-hanging guarantee in dashboard README
This commit is contained in:
@@ -97,11 +97,13 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
|
||||
tabs,
|
||||
activeTab,
|
||||
isReady,
|
||||
bootstrapError,
|
||||
createTab,
|
||||
closeTab,
|
||||
setActiveTab,
|
||||
updateTabTitle,
|
||||
restartActiveTab
|
||||
restartActiveTab,
|
||||
retryBootstrap,
|
||||
} = useTerminalSessions();
|
||||
|
||||
// Get the WebSocket connection for the active session
|
||||
@@ -415,8 +417,9 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
|
||||
}
|
||||
};
|
||||
|
||||
// Determine loading state
|
||||
const isLoading = !isReady || !activeTab || !xtermReady;
|
||||
// Determine loading state — when bootstrapError is set, we are NOT loading
|
||||
// (we have a definitive error to show instead of an indefinite spinner).
|
||||
const isLoading = !isReady || (!activeTab && !bootstrapError) || !xtermReady;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -530,12 +533,27 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
|
||||
|
||||
{/* Terminal container */}
|
||||
<div className="terminal-container" data-testid="terminal-container">
|
||||
{isLoading && (
|
||||
{isLoading && !bootstrapError && (
|
||||
<div className="terminal-loading" data-testid="terminal-loading">
|
||||
<div className="terminal-spinner" />
|
||||
<span>Starting terminal...</span>
|
||||
</div>
|
||||
)}
|
||||
{bootstrapError && !activeTab && (
|
||||
<div className="terminal-loading" data-testid="terminal-bootstrap-error">
|
||||
<div className="terminal-error-content">
|
||||
<span>Failed to start terminal: {bootstrapError}</span>
|
||||
<button
|
||||
className="terminal-retry-btn"
|
||||
onClick={retryBootstrap}
|
||||
data-testid="terminal-retry-btn"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/*
|
||||
Always render the xterm container (no display:none) so that
|
||||
terminal.open() can measure its dimensions even during a tab switch.
|
||||
|
||||
@@ -76,11 +76,13 @@ const defaultSessionState = {
|
||||
tabs: [defaultTab],
|
||||
activeTab: defaultTab,
|
||||
isReady: true,
|
||||
bootstrapError: null,
|
||||
createTab: vi.fn(),
|
||||
closeTab: vi.fn(),
|
||||
setActiveTab: vi.fn(),
|
||||
updateTabTitle: vi.fn(),
|
||||
restartActiveTab: vi.fn(),
|
||||
retryBootstrap: vi.fn(),
|
||||
};
|
||||
|
||||
describe("TerminalModal", () => {
|
||||
@@ -143,6 +145,104 @@ describe("TerminalModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error with retry button when bootstrap fails instead of stuck loading", async () => {
|
||||
const mockRetryBootstrap = vi.fn();
|
||||
mockUseTerminalSessions.mockReturnValue({
|
||||
...defaultSessionState,
|
||||
tabs: [],
|
||||
activeTab: null,
|
||||
bootstrapError: "Server unreachable",
|
||||
retryBootstrap: mockRetryBootstrap,
|
||||
});
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
// Should NOT show the loading spinner
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("terminal-loading")).toBeNull();
|
||||
});
|
||||
|
||||
// Should show the bootstrap error state with retry button
|
||||
expect(screen.getByTestId("terminal-bootstrap-error")).toBeTruthy();
|
||||
expect(screen.getByText(/Failed to start terminal: Server unreachable/)).toBeTruthy();
|
||||
|
||||
const retryBtn = screen.getByTestId("terminal-retry-btn");
|
||||
expect(retryBtn).toBeTruthy();
|
||||
expect(retryBtn.textContent).toContain("Retry");
|
||||
});
|
||||
|
||||
it("retry button calls retryBootstrap from the hook", async () => {
|
||||
const mockRetryBootstrap = vi.fn();
|
||||
mockUseTerminalSessions.mockReturnValue({
|
||||
...defaultSessionState,
|
||||
tabs: [],
|
||||
activeTab: null,
|
||||
bootstrapError: "Connection refused",
|
||||
retryBootstrap: mockRetryBootstrap,
|
||||
});
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
const retryBtn = screen.getByTestId("terminal-retry-btn");
|
||||
fireEvent.click(retryBtn);
|
||||
|
||||
expect(mockRetryBootstrap).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears error state and shows terminal after successful retry", async () => {
|
||||
const mockRetryBootstrap = vi.fn();
|
||||
|
||||
// Start with error state
|
||||
mockUseTerminalSessions.mockReturnValue({
|
||||
...defaultSessionState,
|
||||
tabs: [],
|
||||
activeTab: null,
|
||||
bootstrapError: "Server unreachable",
|
||||
retryBootstrap: mockRetryBootstrap,
|
||||
});
|
||||
|
||||
const { rerender } = render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
// Error state should be shown
|
||||
expect(screen.getByTestId("terminal-bootstrap-error")).toBeTruthy();
|
||||
|
||||
// Simulate successful retry — hook updates state
|
||||
mockUseTerminalSessions.mockReturnValue({
|
||||
...defaultSessionState,
|
||||
tabs: [defaultTab],
|
||||
activeTab: defaultTab,
|
||||
bootstrapError: null,
|
||||
retryBootstrap: mockRetryBootstrap,
|
||||
});
|
||||
|
||||
rerender(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
// Error state should be gone
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("terminal-bootstrap-error")).toBeNull();
|
||||
});
|
||||
|
||||
// Loading spinner should also be gone (xterm will init)
|
||||
// The loading overlay will disappear after xterm initializes
|
||||
expect(screen.queryByTestId("terminal-loading")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not show bootstrap error when activeTab exists (recovered state)", async () => {
|
||||
mockUseTerminalSessions.mockReturnValue({
|
||||
...defaultSessionState,
|
||||
bootstrapError: "Previous error",
|
||||
tabs: [defaultTab],
|
||||
activeTab: defaultTab,
|
||||
});
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
// Bootstrap error should NOT show because activeTab exists
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("terminal-bootstrap-error")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows tabs when multiple sessions exist", async () => {
|
||||
mockUseTerminalSessions.mockReturnValue({
|
||||
...defaultSessionState,
|
||||
|
||||
Reference in New Issue
Block a user