feat(FN-802): route script launches through TerminalModal instead of ScriptRunDialog
- Route saved script launches from App.tsx directly into TerminalModal, removing the ScriptRunDialog component entirely - Add openGeneration handling and initialCommand support to TerminalModal for reliable script execution - Add openGeneration to initialCommand effect dependencies to prevent stale closures - Remove 114 lines of now-unused styles.css rules related to ScriptRunDialog - Update tests: replace ScriptRunDialog tests with TerminalModal execution tests, update App routing tests - Update README to document terminal-based script launch behavior
This commit is contained in:
@@ -121,16 +121,10 @@ Access a fully functional PTY (pseudo-terminal) shell directly from the dashboar
|
||||
- `Ctrl+0` - Reset zoom
|
||||
- `Escape` - Close terminal modal
|
||||
|
||||
### Script Run Dialog
|
||||
When running a saved script from the dashboard (via QuickScripts dropdown in the header or the Run button in Scripts modal), the Scripts modal closes immediately and a dedicated ScriptRunDialog becomes the active foreground view with live output streaming. The dialog uses the existing terminal PTY session infrastructure:
|
||||
WebSocket connection for output, but is non-interactive (no user input).
|
||||
### Saved Scripts
|
||||
Saved scripts (managed via the Scripts modal or QuickScripts dropdown in the header) launch inside the existing interactive Terminal modal instead of a separate read-only output dialog. This gives users a consistent terminal experience and lets them interact with the shell after the script starts — for example, to inspect output files, run follow-up commands, or debug failures.
|
||||
|
||||
**Modal Handoff**: When a script is launched from the Scripts modal, the modal dismisses synchronously before the API call completes so that the ScriptRunDialog always appears as the topmost surface — the user never sees both overlays stacked at once.
|
||||
- **Live Output**: Streams stdout/stderr from the script in real-time via WebSocket
|
||||
- **Script Name & Command**: Shows the script name and resolved command
|
||||
- **Status Indicator**: Shows running/completed/error status
|
||||
- **Exit Code**: Displays exit code when the script completes
|
||||
- **Clean Closure**: Kills the backing PTY session if closed while still running
|
||||
**Modal Handoff**: When a script is launched from the Scripts modal, the modal closes immediately so the Terminal modal becomes the topmost surface — the user never sees both overlays stacked. The script command is sent to the terminal as an `initialCommand` once the PTY session connects. Running a different script while the terminal is already open sends the new command without needing to close and reopen the modal.
|
||||
|
||||
**Features**:
|
||||
- **Real PTY Terminal**: Spawns a real shell (bash/zsh/powershell) using node-pty for authentic terminal behavior
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import type { TaskDetail, TaskCreateInput, Task, ThemeMode } from "@fusion/core";
|
||||
import { fetchConfig, fetchSettings, fetchAuthStatus, updateSettings, updateGlobalSettings, fetchModels, fetchTaskDetail, updateProject, unregisterProject, runScript as runScriptApi } from "./api";
|
||||
import { fetchConfig, fetchSettings, fetchAuthStatus, updateSettings, updateGlobalSettings, fetchModels, fetchTaskDetail, updateProject, unregisterProject } from "./api";
|
||||
import type { ModelInfo, ProjectInfo } from "./api";
|
||||
import { Header } from "./components/Header";
|
||||
import { Board } from "./components/Board";
|
||||
@@ -9,7 +9,6 @@ import { ProjectOverview } from "./components/ProjectOverview";
|
||||
import { SetupWizardModal } from "./components/SetupWizardModal";
|
||||
import { TaskDetailModal } from "./components/TaskDetailModal";
|
||||
import { TerminalModal } from "./components/TerminalModal";
|
||||
import { ScriptRunDialog } from "./components/ScriptRunDialog";
|
||||
import { FileBrowserModal } from "./components/FileBrowserModal";
|
||||
import { ChangedFilesModal } from "./components/ChangedFilesModal";
|
||||
import { SettingsModal } from "./components/SettingsModal";
|
||||
@@ -92,12 +91,6 @@ function AppInner() {
|
||||
const [agentsOpen, setAgentsOpen] = useState(false);
|
||||
const [scriptsOpen, setScriptsOpen] = useState(false);
|
||||
const [terminalInitialCommand, setTerminalInitialCommand] = useState<string | undefined>(undefined);
|
||||
const [scriptRunDialog, setScriptRunDialog] = useState<{
|
||||
open: boolean;
|
||||
scriptName: string;
|
||||
sessionId: string;
|
||||
command: string;
|
||||
}>({ open: false, scriptName: "", sessionId: "", command: "" });
|
||||
const [settingsInitialSection, setSettingsInitialSection] = useState<SectionId | undefined>(undefined);
|
||||
const [setupWizardOpen, setSetupWizardOpen] = useState(false);
|
||||
|
||||
@@ -465,24 +458,17 @@ function AppInner() {
|
||||
// Scripts handlers
|
||||
const handleOpenScripts = useCallback(() => setScriptsOpen(true), []);
|
||||
const handleCloseScripts = useCallback(() => setScriptsOpen(false), []);
|
||||
const handleRunScript = useCallback(async (name: string, _command: string) => {
|
||||
const handleRunScript = useCallback(async (name: string, command: string) => {
|
||||
// Close the scripts modal immediately so the terminal becomes the
|
||||
// topmost surface once it opens (avoids the modal stacking on top
|
||||
// of the script-run dialog during the async API call).
|
||||
// topmost surface once it opens.
|
||||
setScriptsOpen(false);
|
||||
|
||||
try {
|
||||
const result = await runScriptApi(name, undefined, currentProject?.id);
|
||||
setScriptRunDialog({
|
||||
open: true,
|
||||
scriptName: name,
|
||||
sessionId: result.sessionId,
|
||||
command: result.command,
|
||||
});
|
||||
} catch (err: any) {
|
||||
addToast(err.message || `Failed to run script: ${name}`, "error");
|
||||
}
|
||||
}, [addToast, currentProject?.id]);
|
||||
// Launch the script command in the interactive terminal modal.
|
||||
// Reset the initial command ref so the terminal knows to run the
|
||||
// new command even if it is already open.
|
||||
setTerminalInitialCommand(command);
|
||||
setTerminalOpen(true);
|
||||
}, []);
|
||||
|
||||
// Terminal close handler
|
||||
const handleTerminalClose = useCallback(() => {
|
||||
@@ -490,11 +476,6 @@ function AppInner() {
|
||||
setTerminalInitialCommand(undefined);
|
||||
}, []);
|
||||
|
||||
// Script run dialog close handler
|
||||
const handleScriptRunDialogClose = useCallback(() => {
|
||||
setScriptRunDialog((prev) => ({ ...prev, open: false }));
|
||||
}, []);
|
||||
|
||||
// Render main content based on view mode
|
||||
const renderMainContent = () => {
|
||||
if (viewMode === "overview") {
|
||||
@@ -665,13 +646,6 @@ function AppInner() {
|
||||
onClose={handleTerminalClose}
|
||||
initialCommand={terminalInitialCommand}
|
||||
/>
|
||||
<ScriptRunDialog
|
||||
isOpen={scriptRunDialog.open}
|
||||
onClose={handleScriptRunDialogClose}
|
||||
scriptName={scriptRunDialog.scriptName}
|
||||
sessionId={scriptRunDialog.sessionId}
|
||||
command={scriptRunDialog.command}
|
||||
/>
|
||||
<ScriptsModal
|
||||
isOpen={scriptsOpen}
|
||||
onClose={handleCloseScripts}
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { X, Loader2, CheckCircle, XCircle, Terminal } from "lucide-react";
|
||||
import { useTerminal } from "../hooks/useTerminal";
|
||||
import { killPtyTerminalSession } from "../api";
|
||||
import type { ConnectionStatus } from "../hooks/useTerminal";
|
||||
|
||||
interface ScriptRunDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
scriptName: string;
|
||||
sessionId: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
type RunStatus = "running" | "completed" | "error";
|
||||
|
||||
/**
|
||||
* ScriptRunDialog — read-only modal that shows the output of a saved script run.
|
||||
*
|
||||
* Uses the existing PTY terminal session WebSocket plumbing to stream
|
||||
* live stdout/stderr output. The dialog is non-interactive (no input),
|
||||
* focused on observing the script execution and reporting exit status.
|
||||
*
|
||||
* When the dialog closes before the process exits, the backing PTY session
|
||||
* is killed to prevent orphaned sessions.
|
||||
*/
|
||||
export function ScriptRunDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
scriptName,
|
||||
sessionId,
|
||||
command,
|
||||
}: ScriptRunDialogProps) {
|
||||
const [output, setOutput] = useState<string[]>([]);
|
||||
const [exitCode, setExitCode] = useState<number | null>(null);
|
||||
const [status, setStatus] = useState<RunStatus>("running");
|
||||
const outputEndRef = useRef<HTMLDivElement>(null);
|
||||
const isMountedRef = useRef(true);
|
||||
const hasKilledRef = useRef(false);
|
||||
|
||||
// Connect to the PTY session via WebSocket
|
||||
const { connectionStatus, onData, onExit, onConnect, onScrollback } =
|
||||
useTerminal(isOpen ? sessionId : null);
|
||||
|
||||
// Auto-scroll to bottom when new output arrives
|
||||
useEffect(() => {
|
||||
if (outputEndRef.current && typeof outputEndRef.current.scrollIntoView === "function") {
|
||||
outputEndRef.current.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [output]);
|
||||
|
||||
// Subscribe to terminal data
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const unsubData = onData((data: string) => {
|
||||
if (isMountedRef.current) {
|
||||
setOutput((prev) => [...prev, data]);
|
||||
}
|
||||
});
|
||||
|
||||
const unsubScrollback = onScrollback((data: string) => {
|
||||
if (isMountedRef.current) {
|
||||
setOutput([data]);
|
||||
}
|
||||
});
|
||||
|
||||
const unsubExit = onExit((code: number) => {
|
||||
if (isMountedRef.current) {
|
||||
setExitCode(code);
|
||||
setStatus(code === 0 ? "completed" : "error");
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubData();
|
||||
unsubScrollback();
|
||||
unsubExit();
|
||||
};
|
||||
}, [isOpen, onData, onScrollback, onExit]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Kill the PTY session when dialog closes before the process exits
|
||||
const handleClose = useCallback(() => {
|
||||
if (status === "running" && sessionId && !hasKilledRef.current) {
|
||||
hasKilledRef.current = true;
|
||||
killPtyTerminalSession(sessionId).catch(() => {
|
||||
// Best-effort kill — ignore errors
|
||||
});
|
||||
}
|
||||
onClose();
|
||||
}, [status, sessionId, onClose]);
|
||||
|
||||
// Reset state when dialog opens with a new session
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setOutput([]);
|
||||
setExitCode(null);
|
||||
setStatus("running");
|
||||
hasKilledRef.current = false;
|
||||
}
|
||||
}, [isOpen, sessionId]);
|
||||
|
||||
// Handle escape key
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen, handleClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return <Loader2 size={14} className="spin" />;
|
||||
case "completed":
|
||||
return <CheckCircle size={14} style={{ color: "var(--status-success, #22c55e)" }} />;
|
||||
case "error":
|
||||
return <XCircle size={14} style={{ color: "var(--status-error, #ef4444)" }} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = () => {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return connectionStatus === "connected"
|
||||
? "Running..."
|
||||
: "Connecting...";
|
||||
case "completed":
|
||||
return "Completed";
|
||||
case "error":
|
||||
return `Failed (exit code ${exitCode})`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={handleClose} data-testid="script-run-dialog-overlay">
|
||||
<div
|
||||
className="modal script-run-dialog"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-label={`Running script: ${scriptName}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="modal-header">
|
||||
<h2>
|
||||
<Terminal size={18} style={{ marginRight: "8px", verticalAlign: "middle" }} />
|
||||
{scriptName}
|
||||
</h2>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={handleClose}
|
||||
aria-label="Close"
|
||||
data-testid="script-run-dialog-close"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Command */}
|
||||
<div className="script-run-dialog__command" data-testid="script-run-command">
|
||||
<span className="script-run-dialog__command-label">Command:</span>
|
||||
<code className="script-run-dialog__command-text">{command}</code>
|
||||
</div>
|
||||
|
||||
{/* Output area */}
|
||||
<div className="script-run-dialog__output" data-testid="script-run-output">
|
||||
{output.length === 0 && status === "running" ? (
|
||||
<div className="script-run-dialog__output-empty">
|
||||
<Loader2 size={16} className="spin" />
|
||||
<span>Waiting for output...</span>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="script-run-dialog__output-text">
|
||||
{output.join("")}
|
||||
<div ref={outputEndRef} />
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status bar */}
|
||||
<div className="script-run-dialog__status" data-testid="script-run-status">
|
||||
<div className="script-run-dialog__status-indicator">
|
||||
{getStatusIcon()}
|
||||
<span>{getStatusText()}</span>
|
||||
</div>
|
||||
{exitCode !== null && (
|
||||
<span
|
||||
className={`script-run-dialog__exit-code ${
|
||||
exitCode === 0 ? "success" : "error"
|
||||
}`}
|
||||
data-testid="script-run-exit-code"
|
||||
>
|
||||
Exit code: {exitCode}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -32,13 +32,22 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [exitCode, setExitCode] = useState<number | null>(null);
|
||||
const [xtermReady, setXtermReady] = useState(false);
|
||||
const [openGeneration, setOpenGeneration] = useState(0);
|
||||
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const xtermRef = useRef<XTerm | null>(null);
|
||||
const fitAddonRef = useRef<ITerminalAddon | null>(null);
|
||||
const hasInitialCommandRun = useRef(false);
|
||||
const hasInitialCommandRun = useRef<string | false>(false);
|
||||
const xtermInitializedRef = useRef<string | false>(false);
|
||||
|
||||
// Bump open generation whenever the modal opens so the initialCommand
|
||||
// effect re-evaluates after a close/reopen cycle (deps may be identical).
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setOpenGeneration((g) => g + 1);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Use the session management hook
|
||||
const {
|
||||
tabs,
|
||||
@@ -235,16 +244,20 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
|
||||
};
|
||||
}, [xtermReady, activeTab?.id, onData, onScrollback, onConnect, onExit, updateTabTitle]);
|
||||
|
||||
// Run initial command when connected
|
||||
// Run initial command when connected.
|
||||
// Tracks the last command that was sent so that a new command provided
|
||||
// while the terminal is already open (e.g., running a different script)
|
||||
// will be executed immediately without requiring a modal close/reopen.
|
||||
// Depends on openGeneration so the command re-fires after close/reopen.
|
||||
useEffect(() => {
|
||||
if (connectionStatus === "connected" && initialCommand && !hasInitialCommandRun.current && activeTab) {
|
||||
hasInitialCommandRun.current = true;
|
||||
if (connectionStatus === "connected" && initialCommand && hasInitialCommandRun.current !== initialCommand && activeTab) {
|
||||
hasInitialCommandRun.current = initialCommand;
|
||||
// Small delay to let shell initialize
|
||||
setTimeout(() => {
|
||||
sendInput(initialCommand + "\n");
|
||||
}, 500);
|
||||
}
|
||||
}, [connectionStatus, initialCommand, sendInput, activeTab]);
|
||||
}, [connectionStatus, initialCommand, sendInput, activeTab, openGeneration]);
|
||||
|
||||
// Handle keyboard shortcuts (zoom)
|
||||
useEffect(() => {
|
||||
|
||||
@@ -89,7 +89,7 @@ vi.mock("../../hooks/useCurrentProject", () => ({
|
||||
useCurrentProject: () => mockCurrentProjectState,
|
||||
}));
|
||||
|
||||
// Mock useTerminal for ScriptRunDialog
|
||||
// Mock useTerminal for terminal components
|
||||
vi.mock("../../hooks/useTerminal", () => ({
|
||||
useTerminal: () => ({
|
||||
connectionStatus: "connected",
|
||||
@@ -897,7 +897,7 @@ describe("Script run flow", () => {
|
||||
});
|
||||
|
||||
describe("Script-to-terminal modal handoff", () => {
|
||||
it("closes ScriptsModal and opens ScriptRunDialog when Run is clicked", async () => {
|
||||
it("closes ScriptsModal and opens TerminalModal when Run is clicked", async () => {
|
||||
render(<App />);
|
||||
|
||||
// Wait for the app to fully render
|
||||
@@ -906,7 +906,6 @@ describe("Script-to-terminal modal handoff", () => {
|
||||
});
|
||||
|
||||
// Open the Scripts modal via the quick-scripts dropdown "Manage Scripts..." button
|
||||
// First click the scripts trigger button to open the dropdown
|
||||
const scriptsBtn = screen.getByTestId("scripts-btn");
|
||||
await act(async () => {
|
||||
fireEvent.click(scriptsBtn);
|
||||
@@ -925,9 +924,6 @@ describe("Script-to-terminal modal handoff", () => {
|
||||
expect(screen.getByTestId("scripts-modal")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Scripts modal should be visible
|
||||
expect(screen.getByTestId("scripts-modal")).toBeTruthy();
|
||||
|
||||
// Click the Run button on the "build" script
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("run-script-build"));
|
||||
@@ -938,20 +934,78 @@ describe("Script-to-terminal modal handoff", () => {
|
||||
expect(screen.queryByTestId("scripts-modal")).toBeNull();
|
||||
});
|
||||
|
||||
// The ScriptRunDialog should be open with correct script name
|
||||
// The TerminalModal should be open (not the old ScriptRunDialog)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("script-run-dialog-overlay")).toBeTruthy();
|
||||
expect(screen.getByTestId("terminal-modal")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Verify the dialog shows the correct script name and command
|
||||
expect(screen.getByText("build")).toBeTruthy();
|
||||
expect(screen.getByTestId("script-run-command")).toBeTruthy();
|
||||
// ScriptRunDialog should NOT be rendered at all
|
||||
expect(screen.queryByTestId("script-run-dialog-overlay")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps ScriptsModal closed and shows error toast when runScript API fails", async () => {
|
||||
const { runScript: runScriptMock } = await import("../../api");
|
||||
(runScriptMock as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("Script execution failed"));
|
||||
it("allows reopening ScriptsModal after closing TerminalModal", async () => {
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Settings")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Open the Scripts modal and run a script
|
||||
const scriptsBtn = screen.getByTestId("scripts-btn");
|
||||
await act(async () => {
|
||||
fireEvent.click(scriptsBtn);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-scripts-manage")).toBeTruthy();
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("quick-scripts-manage"));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("scripts-modal")).toBeTruthy();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("run-script-build"));
|
||||
});
|
||||
|
||||
// Wait for TerminalModal to open
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("terminal-modal")).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByTestId("scripts-modal")).toBeNull();
|
||||
|
||||
// Close the TerminalModal
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("terminal-close-btn"));
|
||||
});
|
||||
|
||||
// TerminalModal should be closed
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("terminal-modal")).toBeNull();
|
||||
});
|
||||
|
||||
// Reopen the Scripts modal
|
||||
await act(async () => {
|
||||
fireEvent.click(scriptsBtn);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-scripts-manage")).toBeTruthy();
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("quick-scripts-manage"));
|
||||
});
|
||||
|
||||
// Scripts modal should open again cleanly
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("scripts-modal")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not call runScript API — command is sent directly to terminal", async () => {
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -980,80 +1034,13 @@ describe("Script-to-terminal modal handoff", () => {
|
||||
fireEvent.click(screen.getByTestId("run-script-build"));
|
||||
});
|
||||
|
||||
// Scripts modal should be closed immediately (before API call)
|
||||
// TerminalModal should open
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("scripts-modal")).toBeNull();
|
||||
expect(screen.getByTestId("terminal-modal")).toBeTruthy();
|
||||
});
|
||||
|
||||
// ScriptRunDialog should NOT be open since API failed
|
||||
expect(screen.queryByTestId("script-run-dialog-overlay")).toBeNull();
|
||||
|
||||
// Error toast should appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Script execution failed/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("allows reopening ScriptsModal after closing ScriptRunDialog", async () => {
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Settings")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Open the Scripts modal and run a script
|
||||
const scriptsBtn = screen.getByTestId("scripts-btn");
|
||||
await act(async () => {
|
||||
fireEvent.click(scriptsBtn);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-scripts-manage")).toBeTruthy();
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("quick-scripts-manage"));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("scripts-modal")).toBeTruthy();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("run-script-build"));
|
||||
});
|
||||
|
||||
// Wait for ScriptRunDialog to open
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("script-run-dialog-overlay")).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByTestId("scripts-modal")).toBeNull();
|
||||
|
||||
// Close the ScriptRunDialog
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("script-run-dialog-close"));
|
||||
});
|
||||
|
||||
// ScriptRunDialog should be closed
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("script-run-dialog-overlay")).toBeNull();
|
||||
});
|
||||
|
||||
// Reopen the Scripts modal
|
||||
await act(async () => {
|
||||
fireEvent.click(scriptsBtn);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("quick-scripts-manage")).toBeTruthy();
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("quick-scripts-manage"));
|
||||
});
|
||||
|
||||
// Scripts modal should open again cleanly
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("scripts-modal")).toBeTruthy();
|
||||
});
|
||||
// runScript API should NOT have been called — command goes directly to terminal
|
||||
expect(runScript).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -452,6 +452,97 @@ describe("TerminalModal", () => {
|
||||
|
||||
expect(mockRestartActiveTab).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// --- initialCommand / script launch behavior ---
|
||||
describe("initialCommand execution", () => {
|
||||
it("sends initialCommand to terminal when connected", async () => {
|
||||
mockUseTerminal.mockReturnValue(
|
||||
createMockTerminalState({ connectionStatus: "connected" })
|
||||
);
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="npm run build" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSendInput).toHaveBeenCalledWith("npm run build\n");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not send the same initialCommand twice on re-renders", async () => {
|
||||
mockUseTerminal.mockReturnValue(
|
||||
createMockTerminalState({ connectionStatus: "connected" })
|
||||
);
|
||||
|
||||
const { rerender } = render(
|
||||
<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="npm run build" />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSendInput).toHaveBeenCalledWith("npm run build\n");
|
||||
});
|
||||
|
||||
const callCount = mockSendInput.mock.calls.length;
|
||||
|
||||
// Re-render with same props
|
||||
rerender(
|
||||
<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="npm run build" />
|
||||
);
|
||||
|
||||
// Should not send the command again
|
||||
expect(mockSendInput).toHaveBeenCalledTimes(callCount);
|
||||
});
|
||||
|
||||
it("sends a new initialCommand when it changes while terminal is open", async () => {
|
||||
mockUseTerminal.mockReturnValue(
|
||||
createMockTerminalState({ connectionStatus: "connected" })
|
||||
);
|
||||
|
||||
const { rerender } = render(
|
||||
<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="npm run build" />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSendInput).toHaveBeenCalledWith("npm run build\n");
|
||||
});
|
||||
|
||||
// Change the command (e.g., user runs a different script)
|
||||
rerender(
|
||||
<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="pnpm test" />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSendInput).toHaveBeenCalledWith("pnpm test\n");
|
||||
});
|
||||
});
|
||||
|
||||
it("resends command after modal close and reopen", async () => {
|
||||
mockUseTerminal.mockReturnValue(
|
||||
createMockTerminalState({ connectionStatus: "connected" })
|
||||
);
|
||||
|
||||
const { rerender } = render(
|
||||
<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="npm run build" />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSendInput).toHaveBeenCalledWith("npm run build\n");
|
||||
});
|
||||
|
||||
// Close the modal
|
||||
rerender(
|
||||
<TerminalModal isOpen={false} onClose={mockOnClose} initialCommand="npm run build" />
|
||||
);
|
||||
|
||||
// Reopen with the same command
|
||||
mockSendInput.mockClear();
|
||||
rerender(
|
||||
<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="npm run build" />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSendInput).toHaveBeenCalledWith("npm run build\n");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- Mobile layout regression tests ---
|
||||
|
||||
@@ -294,120 +294,6 @@ body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ── Script Run Dialog ─────────────────────────────────────────────────── */
|
||||
|
||||
.script-run-dialog {
|
||||
width: 720px;
|
||||
max-width: 90vw;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.script-run-dialog__command {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.script-run-dialog__command-label {
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.script-run-dialog__command-text {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.script-run-dialog__output {
|
||||
flex: 1;
|
||||
min-height: 300px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
background: #1e1e1e;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.script-run-dialog__output-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #888;
|
||||
font-size: 13px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.script-run-dialog__output-text {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #d4d4d4;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.script-run-dialog__status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--border-primary);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.script-run-dialog__status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.script-run-dialog__exit-code {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.script-run-dialog__exit-code.success {
|
||||
color: var(--status-success, #22c55e);
|
||||
}
|
||||
|
||||
.script-run-dialog__exit-code.error {
|
||||
color: var(--status-error, #ef4444);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.script-run-dialog {
|
||||
width: 95vw;
|
||||
max-height: 85vh;
|
||||
}
|
||||
|
||||
.script-run-dialog__command {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.script-run-dialog__output {
|
||||
min-height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.btn-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user