feat(KB-643): add Dashboard Scripts UI feature
- Add scripts field to ProjectSettings type for custom dashboard scripts - Update ScriptsModal with improved UI for script management - Add QuickScriptsDropdown component for quick script access - Implement scripts API routes for CRUD operations - Update CLI with multi-project commands (project add/remove/list/show/set-default/detect) - Various UI improvements and bug fixes across dashboard components - Migration support for multi-project architecture
This commit is contained in:
@@ -1355,6 +1355,46 @@ export function createWorkflowStepFromTemplate(templateId: string): Promise<Work
|
||||
});
|
||||
}
|
||||
|
||||
// ── Scripts API ────────────────────────────────────────────────────────
|
||||
|
||||
/** Script entry returned from the API */
|
||||
export interface ScriptEntry {
|
||||
name: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
/** Result of running a script */
|
||||
export interface ScriptRunResult {
|
||||
output: string;
|
||||
exitCode: number;
|
||||
}
|
||||
|
||||
/** Fetch all saved scripts from project settings */
|
||||
export function fetchScripts(): Promise<Record<string, string>> {
|
||||
return api<Record<string, string>>("/scripts");
|
||||
}
|
||||
|
||||
/** Add or update a script */
|
||||
export function addScript(name: string, command: string): Promise<ScriptEntry> {
|
||||
return api<ScriptEntry>("/scripts", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, command }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove a script by name */
|
||||
export function removeScript(name: string): Promise<void> {
|
||||
return api<void>(`/scripts/${encodeURIComponent(name)}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
/** Run a saved script by name */
|
||||
export function runScript(name: string, args?: string[]): Promise<ScriptRunResult> {
|
||||
return api<ScriptRunResult>(`/scripts/${encodeURIComponent(name)}/run`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ args }),
|
||||
});
|
||||
}
|
||||
|
||||
// ── AI Text Refinement API ────────────────────────────────────────────
|
||||
|
||||
/** Refinement types for AI text refinement */
|
||||
@@ -1953,28 +1993,6 @@ export function updateProject(id: string, updates: Partial<ProjectInfo>): Promis
|
||||
});
|
||||
}
|
||||
|
||||
// ── Scripts API ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Fetch all saved scripts */
|
||||
export function fetchScripts(): Promise<Record<string, string>> {
|
||||
return api<Record<string, string>>("/scripts");
|
||||
}
|
||||
|
||||
/** Add or update a script */
|
||||
export function addScript(name: string, command: string): Promise<Record<string, string>> {
|
||||
return api<Record<string, string>>("/scripts", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, command }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove a script */
|
||||
export function removeScript(name: string): Promise<Record<string, string>> {
|
||||
return api<Record<string, string>>(`/scripts/${encodeURIComponent(name)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
// ── Task Diff API ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Task diff information */
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { X, Plus, Play, Trash2, Terminal, Check, AlertCircle } from "lucide-react";
|
||||
import {
|
||||
fetchScripts,
|
||||
addScript,
|
||||
removeScript,
|
||||
} from "../api";
|
||||
import { fetchScripts, addScript, removeScript, type ScriptEntry } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import {
|
||||
X,
|
||||
Plus,
|
||||
Play,
|
||||
Trash2,
|
||||
Terminal,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
|
||||
interface ScriptsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
/** Callback when user wants to run a script - opens terminal modal */
|
||||
onRunScript?: (name: string, command: string) => void;
|
||||
}
|
||||
|
||||
@@ -24,18 +28,28 @@ const EMPTY_FORM: ScriptFormData = {
|
||||
command: "",
|
||||
};
|
||||
|
||||
/** Validate script name: alphanumeric, hyphens, underscores only */
|
||||
function isValidScriptName(name: string): boolean {
|
||||
return /^[a-zA-Z0-9_-]+$/.test(name);
|
||||
}
|
||||
|
||||
/** Truncate command for display */
|
||||
function truncateCommand(command: string, maxLength: number = 60): string {
|
||||
if (command.length <= maxLength) return command;
|
||||
return command.slice(0, maxLength - 3) + "...";
|
||||
}
|
||||
|
||||
export function ScriptsModal({ isOpen, onClose, addToast, onRunScript }: ScriptsModalProps) {
|
||||
const [scripts, setScripts] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [editingName, setEditingName] = useState<string | null>(null);
|
||||
const [isEditing, setIsEditing] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<ScriptFormData>(EMPTY_FORM);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleteConfirmName, setDeleteConfirmName] = useState<string | null>(null);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [nameError, setNameError] = useState<string | null>(null);
|
||||
|
||||
const loadScripts = useCallback(async () => {
|
||||
if (!isOpen) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await fetchScripts();
|
||||
@@ -45,7 +59,7 @@ export function ScriptsModal({ isOpen, onClose, addToast, onRunScript }: Scripts
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [isOpen, addToast]);
|
||||
}, [addToast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
@@ -53,113 +67,103 @@ export function ScriptsModal({ isOpen, onClose, addToast, onRunScript }: Scripts
|
||||
}
|
||||
}, [isOpen, loadScripts]);
|
||||
|
||||
const validateScriptName = (name: string): string | null => {
|
||||
if (!name.trim()) {
|
||||
return "Script name is required";
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(name.trim())) {
|
||||
return "Name must be alphanumeric with hyphens and underscores only (no spaces)";
|
||||
}
|
||||
// Check for reserved names
|
||||
const reservedNames = ["run", "list", "add", "remove", "delete", "help"];
|
||||
if (reservedNames.includes(name.trim().toLowerCase())) {
|
||||
return `Script name '${name.trim()}' is reserved`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
setIsCreating(true);
|
||||
setEditingName(null);
|
||||
setIsEditing(null);
|
||||
setForm(EMPTY_FORM);
|
||||
setValidationError(null);
|
||||
setNameError(null);
|
||||
}, []);
|
||||
|
||||
const handleEdit = useCallback((name: string, command: string) => {
|
||||
setIsEditing(name);
|
||||
setIsCreating(false);
|
||||
setEditingName(name);
|
||||
setForm({ name, command });
|
||||
setValidationError(null);
|
||||
setNameError(null);
|
||||
}, []);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setIsEditing(null);
|
||||
setIsCreating(false);
|
||||
setEditingName(null);
|
||||
setForm(EMPTY_FORM);
|
||||
setValidationError(null);
|
||||
setNameError(null);
|
||||
}, []);
|
||||
|
||||
const handleNameChange = useCallback((name: string) => {
|
||||
setForm((prev) => ({ ...prev, name }));
|
||||
if (name && !isValidScriptName(name)) {
|
||||
setNameError("Name must contain only letters, numbers, hyphens, and underscores (no spaces)");
|
||||
} else {
|
||||
setNameError(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const trimmedName = form.name.trim();
|
||||
const trimmedCommand = form.command.trim();
|
||||
|
||||
// Validate name
|
||||
const nameError = validateScriptName(trimmedName);
|
||||
if (nameError) {
|
||||
setValidationError(nameError);
|
||||
if (!trimmedName) {
|
||||
addToast("Script name is required", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidScriptName(trimmedName)) {
|
||||
addToast("Script name must contain only letters, numbers, hyphens, and underscores (no spaces)", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!trimmedCommand) {
|
||||
setValidationError("Command is required");
|
||||
addToast("Script command is required", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setValidationError(null);
|
||||
|
||||
try {
|
||||
await addScript(trimmedName, trimmedCommand);
|
||||
addToast(
|
||||
isCreating ? `Script '${trimmedName}' created` : `Script '${trimmedName}' updated`,
|
||||
"success"
|
||||
);
|
||||
addToast(isEditing ? "Script updated" : "Script created", "success");
|
||||
setIsEditing(null);
|
||||
setIsCreating(false);
|
||||
setEditingName(null);
|
||||
setForm(EMPTY_FORM);
|
||||
setNameError(null);
|
||||
await loadScripts();
|
||||
} catch (err: any) {
|
||||
const message = err.message || "Failed to save script";
|
||||
setValidationError(message);
|
||||
addToast(message, "error");
|
||||
if (err.message?.includes("already exists")) {
|
||||
addToast("A script with this name already exists", "error");
|
||||
} else {
|
||||
addToast(err.message || "Failed to save script", "error");
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [form, isCreating, addToast, loadScripts]);
|
||||
}, [form, isEditing, addToast, loadScripts]);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (name: string) => {
|
||||
try {
|
||||
await removeScript(name);
|
||||
addToast(`Script '${name}' deleted`, "success");
|
||||
setDeleteConfirmName(null);
|
||||
if (editingName === name) {
|
||||
setEditingName(null);
|
||||
setForm(EMPTY_FORM);
|
||||
}
|
||||
await loadScripts();
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to delete script", "error");
|
||||
const handleDelete = useCallback(async (name: string) => {
|
||||
try {
|
||||
await removeScript(name);
|
||||
addToast("Script deleted", "success");
|
||||
setDeleteConfirmName(null);
|
||||
if (isEditing === name) {
|
||||
setIsEditing(null);
|
||||
setForm(EMPTY_FORM);
|
||||
}
|
||||
},
|
||||
[editingName, addToast, loadScripts]
|
||||
);
|
||||
await loadScripts();
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to delete script", "error");
|
||||
}
|
||||
}, [isEditing, addToast, loadScripts]);
|
||||
|
||||
const handleRunScript = useCallback(
|
||||
(name: string, command: string) => {
|
||||
if (onRunScript) {
|
||||
onRunScript(name, command);
|
||||
} else {
|
||||
addToast("Terminal not available", "error");
|
||||
}
|
||||
},
|
||||
[onRunScript, addToast]
|
||||
);
|
||||
const handleRun = useCallback((name: string, command: string) => {
|
||||
if (onRunScript) {
|
||||
onRunScript(name, command);
|
||||
}
|
||||
}, [onRunScript]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const isEditing = isCreating || editingName !== null;
|
||||
const scriptEntries = Object.entries(scripts).sort(([a], [b]) => a.localeCompare(b));
|
||||
const isEditingAny = isCreating || isEditing !== null;
|
||||
const scriptEntries: ScriptEntry[] = Object.entries(scripts).map(([name, command]) => ({
|
||||
name,
|
||||
command,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose} data-testid="scripts-modal">
|
||||
@@ -167,12 +171,12 @@ export function ScriptsModal({ isOpen, onClose, addToast, onRunScript }: Scripts
|
||||
className="modal scripts-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-label="Scripts Manager"
|
||||
aria-label="Scripts"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="modal-header">
|
||||
<h2 style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<Terminal size={18} />
|
||||
<h2>
|
||||
<Terminal size={18} style={{ marginRight: "8px", verticalAlign: "middle" }} />
|
||||
Scripts
|
||||
</h2>
|
||||
<button className="btn-icon" onClick={onClose} aria-label="Close">
|
||||
@@ -182,319 +186,296 @@ export function ScriptsModal({ isOpen, onClose, addToast, onRunScript }: Scripts
|
||||
|
||||
<div className="modal-body" style={{ padding: "16px", maxHeight: "70vh", overflowY: "auto" }}>
|
||||
{loading ? (
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: "32px",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
data-testid="scripts-loading"
|
||||
>
|
||||
Loading...
|
||||
<div style={{ textAlign: "center", padding: "32px", color: "var(--text-secondary)" }}>
|
||||
<Loader2 size={24} className="spin" style={{ margin: "0 auto 8px", display: "block" }} />
|
||||
Loading scripts...
|
||||
</div>
|
||||
) : isEditingAny ? (
|
||||
/* Form for create/edit */
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="script-name"
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "4px",
|
||||
fontSize: "13px",
|
||||
fontWeight: 500,
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
Script Name
|
||||
</label>
|
||||
<input
|
||||
id="script-name"
|
||||
type="text"
|
||||
className="input"
|
||||
value={form.name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="e.g., build, test, lint"
|
||||
disabled={saving || isEditing !== null}
|
||||
data-testid="script-name-input"
|
||||
style={{
|
||||
width: "100%",
|
||||
borderColor: nameError ? "var(--status-error, #ef4444)" : undefined,
|
||||
}}
|
||||
/>
|
||||
{nameError && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--status-error, #ef4444)",
|
||||
marginTop: "4px",
|
||||
}}
|
||||
data-testid="script-name-error"
|
||||
>
|
||||
{nameError}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
color: "var(--text-secondary)",
|
||||
marginTop: "4px",
|
||||
}}
|
||||
>
|
||||
Letters, numbers, hyphens, and underscores only
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="script-command"
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "4px",
|
||||
fontSize: "13px",
|
||||
fontWeight: 500,
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
Command
|
||||
</label>
|
||||
<textarea
|
||||
id="script-command"
|
||||
className="input"
|
||||
value={form.command}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, command: e.target.value }))}
|
||||
placeholder="e.g., npm run build"
|
||||
rows={3}
|
||||
disabled={saving}
|
||||
data-testid="script-command-input"
|
||||
style={{
|
||||
width: "100%",
|
||||
resize: "vertical",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: "8px", justifyContent: "flex-end" }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={handleCancel}
|
||||
disabled={saving}
|
||||
data-testid="script-cancel-btn"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !!nameError}
|
||||
data-testid="script-save-btn"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 size={14} className="spin" style={{ marginRight: "6px" }} />
|
||||
Saving...
|
||||
</>
|
||||
) : isEditing ? (
|
||||
"Update"
|
||||
) : (
|
||||
"Create"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* List view */
|
||||
<>
|
||||
{/* Script List */}
|
||||
{!isEditing && (
|
||||
<>
|
||||
{scriptEntries.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: "32px",
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
data-testid="scripts-empty-state"
|
||||
>
|
||||
No scripts defined yet. Add your first script to run quick commands.
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "16px",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: "13px", color: "var(--text-secondary)" }}>
|
||||
{scriptEntries.length === 0
|
||||
? "No scripts defined"
|
||||
: `${scriptEntries.length} script${scriptEntries.length === 1 ? "" : "s"}`}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleCreate}
|
||||
data-testid="add-script-btn"
|
||||
style={{ display: "flex", alignItems: "center", gap: "6px" }}
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add Script
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{scriptEntries.length > 0 && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||||
{scriptEntries.map(([name, command]) => (
|
||||
<div
|
||||
key={name}
|
||||
className="script-card"
|
||||
data-testid={`script-item-${name}`}
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
border: "1px solid var(--border-primary)",
|
||||
borderRadius: "8px",
|
||||
background: "var(--bg-secondary)",
|
||||
}}
|
||||
>
|
||||
{scriptEntries.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: "32px",
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: "14px",
|
||||
border: "1px dashed var(--border-primary)",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
data-testid="empty-state"
|
||||
>
|
||||
<Terminal size={32} style={{ margin: "0 auto 12px", opacity: 0.5 }} />
|
||||
<div>No scripts defined yet.</div>
|
||||
<div style={{ marginTop: "4px", fontSize: "12px" }}>
|
||||
Add scripts to quickly run common commands from the dashboard.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||||
{scriptEntries.map((script) => (
|
||||
<div
|
||||
key={script.name}
|
||||
className="script-card"
|
||||
data-testid={`script-${script.name}`}
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
border: "1px solid var(--border-primary)",
|
||||
borderRadius: "8px",
|
||||
background: "var(--bg-secondary)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
marginBottom: "4px",
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
marginBottom: "4px",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 600, fontSize: "14px" }}>{name}</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--text-secondary)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
title={command}
|
||||
>
|
||||
{command.length > 60 ? `${command.slice(0, 60)}...` : command}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "4px",
|
||||
marginLeft: "8px",
|
||||
flexShrink: 0,
|
||||
fontWeight: 600,
|
||||
fontSize: "14px",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => handleRunScript(name, command)}
|
||||
title={`Run ${name}`}
|
||||
aria-label={`Run script ${name}`}
|
||||
data-testid={`run-script-${name}`}
|
||||
>
|
||||
<Play size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => handleEdit(name, command)}
|
||||
title="Edit"
|
||||
aria-label={`Edit script ${name}`}
|
||||
data-testid={`edit-script-${name}`}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
|
||||
</svg>
|
||||
</button>
|
||||
{deleteConfirmName === name ? (
|
||||
<div
|
||||
style={{ display: "flex", gap: "4px", alignItems: "center" }}
|
||||
>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => handleDelete(name)}
|
||||
title="Confirm delete"
|
||||
aria-label={`Confirm delete script ${name}`}
|
||||
style={{ color: "var(--status-error, #ef4444)" }}
|
||||
>
|
||||
<Check size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => setDeleteConfirmName(null)}
|
||||
title="Cancel delete"
|
||||
aria-label="Cancel delete"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => setDeleteConfirmName(name)}
|
||||
title="Delete"
|
||||
aria-label={`Delete script ${name}`}
|
||||
data-testid={`delete-script-${name}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{script.name}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--text-secondary)",
|
||||
fontFamily: "monospace",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
title={script.command}
|
||||
>
|
||||
{truncateCommand(script.command)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Edit / Create form */}
|
||||
{isEditing && (
|
||||
<div
|
||||
style={{
|
||||
padding: "16px",
|
||||
border: "1px solid var(--border-primary)",
|
||||
borderRadius: "8px",
|
||||
background: "var(--bg-secondary)",
|
||||
}}
|
||||
data-testid="script-form"
|
||||
>
|
||||
<h3 style={{ margin: "0 0 12px", fontSize: "14px", fontWeight: 600 }}>
|
||||
{isCreating ? "New Script" : "Edit Script"}
|
||||
</h3>
|
||||
|
||||
{/* Validation error */}
|
||||
{validationError && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
padding: "8px 12px",
|
||||
marginBottom: "12px",
|
||||
background: "rgba(239, 68, 68, 0.1)",
|
||||
border: "1px solid rgba(239, 68, 68, 0.3)",
|
||||
borderRadius: "6px",
|
||||
color: "var(--status-error, #ef4444)",
|
||||
fontSize: "13px",
|
||||
}}
|
||||
data-testid="script-validation-error"
|
||||
>
|
||||
<AlertCircle size={14} />
|
||||
{validationError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
fontSize: "12px",
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: "4px",
|
||||
}}
|
||||
>
|
||||
Script Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, name: e.target.value }))
|
||||
}
|
||||
placeholder="e.g. build, test, deploy"
|
||||
disabled={!isCreating}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid var(--border-primary)",
|
||||
background: isCreating ? "var(--bg-primary)" : "var(--bg-tertiary)",
|
||||
color: "var(--text-primary)",
|
||||
fontSize: "13px",
|
||||
}}
|
||||
data-testid="script-name-input"
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
color: "var(--text-secondary)",
|
||||
marginTop: "4px",
|
||||
}}
|
||||
>
|
||||
Alphanumeric with hyphens and underscores only. No spaces.
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "4px",
|
||||
marginLeft: "8px",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => handleRun(script.name, script.command)}
|
||||
title="Run script"
|
||||
aria-label={`Run ${script.name}`}
|
||||
data-testid={`run-script-${script.name}`}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
padding: "4px 10px",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
>
|
||||
<Play size={12} />
|
||||
Run
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => handleEdit(script.name, script.command)}
|
||||
title="Edit"
|
||||
aria-label={`Edit ${script.name}`}
|
||||
data-testid={`edit-script-${script.name}`}
|
||||
>
|
||||
<Plus size={14} style={{ transform: "rotate(45deg)" }} />
|
||||
</button>
|
||||
{deleteConfirmName === script.name ? (
|
||||
<div style={{ display: "flex", gap: "4px", alignItems: "center" }}>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => handleDelete(script.name)}
|
||||
title="Confirm delete"
|
||||
aria-label={`Confirm delete ${script.name}`}
|
||||
data-testid={`confirm-delete-script-${script.name}`}
|
||||
style={{ color: "var(--status-error, #ef4444)" }}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => setDeleteConfirmName(null)}
|
||||
title="Cancel delete"
|
||||
aria-label="Cancel delete"
|
||||
data-testid={`cancel-delete-script-${script.name}`}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => setDeleteConfirmName(script.name)}
|
||||
title="Delete"
|
||||
aria-label={`Delete ${script.name}`}
|
||||
data-testid={`delete-script-${script.name}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Command */}
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
fontSize: "12px",
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: "4px",
|
||||
}}
|
||||
>
|
||||
Command
|
||||
</label>
|
||||
<textarea
|
||||
value={form.command}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, command: e.target.value }))
|
||||
}
|
||||
placeholder="e.g. npm run build"
|
||||
rows={3}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid var(--border-primary)",
|
||||
background: "var(--bg-primary)",
|
||||
color: "var(--text-primary)",
|
||||
fontSize: "13px",
|
||||
fontFamily: "monospace",
|
||||
resize: "vertical",
|
||||
}}
|
||||
data-testid="script-command-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Form actions */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: "8px",
|
||||
marginTop: "4px",
|
||||
}}
|
||||
>
|
||||
<button className="btn btn-secondary" onClick={handleCancel} disabled={saving}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !form.name.trim() || !form.command.trim()}
|
||||
data-testid="save-script-btn"
|
||||
>
|
||||
{saving ? "Saving..." : isCreating ? "Create" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{!isEditing && (
|
||||
<div
|
||||
className="modal-footer"
|
||||
style={{ padding: "12px 16px", borderTop: "1px solid var(--border-primary)" }}
|
||||
>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleCreate}
|
||||
style={{ display: "flex", alignItems: "center", gap: "6px" }}
|
||||
data-testid="add-script-btn"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add Script
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { ScriptsModal } from "../ScriptsModal";
|
||||
import type { ScriptEntry } from "../../api";
|
||||
|
||||
const mockScripts: Record<string, string> = {
|
||||
build: "npm run build",
|
||||
test: "pnpm test",
|
||||
lint: "eslint src/",
|
||||
lint: "eslint src --ext .ts,.tsx",
|
||||
};
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchScripts: vi.fn(() => Promise.resolve({})),
|
||||
addScript: vi.fn(() => Promise.resolve({})),
|
||||
removeScript: vi.fn(() => Promise.resolve({})),
|
||||
addScript: vi.fn(() => Promise.resolve({ name: "new-script", command: "echo hello" })),
|
||||
removeScript: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
import { fetchScripts, addScript, removeScript } from "../../api";
|
||||
import {
|
||||
fetchScripts,
|
||||
addScript,
|
||||
removeScript,
|
||||
} from "../../api";
|
||||
|
||||
const onClose = vi.fn();
|
||||
const addToast = vi.fn();
|
||||
@@ -27,7 +32,7 @@ beforeEach(() => {
|
||||
describe("ScriptsModal", () => {
|
||||
it("does not render when closed", () => {
|
||||
const { container } = render(
|
||||
<ScriptsModal isOpen={false} onClose={onClose} addToast={addToast} />
|
||||
<ScriptsModal isOpen={false} onClose={onClose} addToast={addToast} onRunScript={onRunScript} />
|
||||
);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
@@ -35,7 +40,9 @@ describe("ScriptsModal", () => {
|
||||
it("renders list of scripts", async () => {
|
||||
vi.mocked(fetchScripts).mockResolvedValueOnce(mockScripts);
|
||||
|
||||
render(<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} />);
|
||||
render(
|
||||
<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} onRunScript={onRunScript} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("build")).toBeInTheDocument();
|
||||
@@ -47,286 +54,349 @@ describe("ScriptsModal", () => {
|
||||
it("shows empty state when no scripts exist", async () => {
|
||||
vi.mocked(fetchScripts).mockResolvedValueOnce({});
|
||||
|
||||
render(<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("scripts-empty-state")).toBeInTheDocument();
|
||||
expect(screen.getByText(/No scripts defined yet/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("opens create form when Add button is clicked", async () => {
|
||||
vi.mocked(fetchScripts).mockResolvedValueOnce({});
|
||||
|
||||
render(<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("scripts-empty-state")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("add-script-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("script-form")).toBeInTheDocument();
|
||||
expect(screen.getByText("New Script")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("validates script name - rejects empty name (button disabled)", async () => {
|
||||
vi.mocked(fetchScripts).mockResolvedValueOnce({});
|
||||
|
||||
render(<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("scripts-empty-state")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("add-script-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("script-form")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Fill in command only
|
||||
fireEvent.change(screen.getByTestId("script-command-input"), {
|
||||
target: { value: "echo hello" },
|
||||
});
|
||||
|
||||
// Save button should be disabled when name is empty
|
||||
const saveButton = screen.getByTestId("save-script-btn") as HTMLButtonElement;
|
||||
expect(saveButton.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("validates script name - rejects invalid characters", async () => {
|
||||
vi.mocked(fetchScripts).mockResolvedValueOnce({});
|
||||
|
||||
render(<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("scripts-empty-state")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("add-script-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("script-form")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Fill in name with spaces
|
||||
fireEvent.change(screen.getByTestId("script-name-input"), {
|
||||
target: { value: "my script" },
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByTestId("script-command-input"), {
|
||||
target: { value: "echo hello" },
|
||||
});
|
||||
|
||||
// Click save
|
||||
fireEvent.click(screen.getByTestId("save-script-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("script-validation-error")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Name must be alphanumeric with hyphens and underscores only/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("validates script name - rejects reserved names", async () => {
|
||||
vi.mocked(fetchScripts).mockResolvedValueOnce({});
|
||||
|
||||
render(<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("scripts-empty-state")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("add-script-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("script-form")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Fill in reserved name
|
||||
fireEvent.change(screen.getByTestId("script-name-input"), {
|
||||
target: { value: "run" },
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByTestId("script-command-input"), {
|
||||
target: { value: "echo hello" },
|
||||
});
|
||||
|
||||
// Click save
|
||||
fireEvent.click(screen.getByTestId("save-script-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("script-validation-error")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Script name 'run' is reserved/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("creates a new script successfully", async () => {
|
||||
vi.mocked(fetchScripts).mockResolvedValueOnce({});
|
||||
vi.mocked(addScript).mockResolvedValueOnce({ deploy: "npm run deploy" });
|
||||
|
||||
render(<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("scripts-empty-state")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("add-script-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("script-form")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Fill in form
|
||||
fireEvent.change(screen.getByTestId("script-name-input"), {
|
||||
target: { value: "deploy" },
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByTestId("script-command-input"), {
|
||||
target: { value: "npm run deploy" },
|
||||
});
|
||||
|
||||
// Click save
|
||||
fireEvent.click(screen.getByTestId("save-script-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addScript).toHaveBeenCalledWith("deploy", "npm run deploy");
|
||||
expect(addToast).toHaveBeenCalledWith("Script 'deploy' created", "success");
|
||||
});
|
||||
});
|
||||
|
||||
it("runs a script when run button is clicked", async () => {
|
||||
vi.mocked(fetchScripts).mockResolvedValueOnce(mockScripts);
|
||||
|
||||
render(
|
||||
<ScriptsModal
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
addToast={addToast}
|
||||
onRunScript={onRunScript}
|
||||
/>
|
||||
<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} onRunScript={onRunScript} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("build")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("empty-state")).toBeInTheDocument();
|
||||
});
|
||||
// Use getAllByText since the header also shows "No scripts defined" text
|
||||
expect(screen.getAllByText(/No scripts defined/).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("opens create form when Add Script button is clicked", async () => {
|
||||
vi.mocked(fetchScripts).mockResolvedValueOnce({});
|
||||
|
||||
render(
|
||||
<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} onRunScript={onRunScript} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("add-script-btn")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("add-script-btn"));
|
||||
|
||||
expect(screen.getByTestId("script-name-input")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("script-command-input")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("submits new script", async () => {
|
||||
vi.mocked(fetchScripts)
|
||||
.mockResolvedValueOnce({})
|
||||
.mockResolvedValueOnce({});
|
||||
|
||||
render(
|
||||
<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} onRunScript={onRunScript} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("add-script-btn")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("add-script-btn"));
|
||||
|
||||
const nameInput = screen.getByTestId("script-name-input");
|
||||
const commandInput = screen.getByTestId("script-command-input");
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: "new-script" } });
|
||||
fireEvent.change(commandInput, { target: { value: "echo hello" } });
|
||||
|
||||
fireEvent.click(screen.getByTestId("script-save-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addScript).toHaveBeenCalledWith("new-script", "echo hello");
|
||||
expect(addToast).toHaveBeenCalledWith("Script created", "success");
|
||||
});
|
||||
});
|
||||
|
||||
it("validates script name (alphanumeric, hyphens, underscores only)", async () => {
|
||||
vi.mocked(fetchScripts).mockResolvedValueOnce({});
|
||||
|
||||
render(
|
||||
<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} onRunScript={onRunScript} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("add-script-btn")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("add-script-btn"));
|
||||
|
||||
const nameInput = screen.getByTestId("script-name-input");
|
||||
fireEvent.change(nameInput, { target: { value: "invalid name with spaces" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("script-name-error")).toBeInTheDocument();
|
||||
});
|
||||
// Verify the error message contains expected text
|
||||
expect(screen.getByTestId("script-name-error").textContent).toContain("letters");
|
||||
});
|
||||
|
||||
it("allows valid script names with hyphens and underscores", async () => {
|
||||
vi.mocked(fetchScripts)
|
||||
.mockResolvedValueOnce({})
|
||||
.mockResolvedValueOnce({});
|
||||
|
||||
render(
|
||||
<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} onRunScript={onRunScript} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("add-script-btn")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("add-script-btn"));
|
||||
|
||||
const nameInput = screen.getByTestId("script-name-input");
|
||||
const commandInput = screen.getByTestId("script-command-input");
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: "my-script_v2" } });
|
||||
fireEvent.change(commandInput, { target: { value: "echo test" } });
|
||||
|
||||
fireEvent.click(screen.getByTestId("script-save-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addScript).toHaveBeenCalledWith("my-script_v2", "echo test");
|
||||
});
|
||||
});
|
||||
|
||||
it("runs script when Run button is clicked", async () => {
|
||||
vi.mocked(fetchScripts).mockResolvedValueOnce(mockScripts);
|
||||
|
||||
render(
|
||||
<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} onRunScript={onRunScript} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("run-script-build")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("run-script-build"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onRunScript).toHaveBeenCalledWith("build", "npm run build");
|
||||
});
|
||||
expect(onRunScript).toHaveBeenCalledWith("build", "npm run build");
|
||||
});
|
||||
|
||||
it("deletes a script with confirmation", async () => {
|
||||
it("shows delete confirmation", async () => {
|
||||
vi.mocked(fetchScripts).mockResolvedValueOnce(mockScripts);
|
||||
vi.mocked(removeScript).mockResolvedValueOnce({ test: "pnpm test", lint: "eslint src/" });
|
||||
|
||||
render(<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} />);
|
||||
render(
|
||||
<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} onRunScript={onRunScript} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("build")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("run-script-build")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click delete button
|
||||
fireEvent.click(screen.getByTestId("delete-script-build"));
|
||||
|
||||
// Should show confirm/cancel buttons
|
||||
// Confirm delete buttons should appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Confirm delete")).toBeInTheDocument();
|
||||
expect(screen.getByTitle("Cancel delete")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("confirm-delete-script-build")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("cancel-delete-script-build")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes script when confirmed", async () => {
|
||||
vi.mocked(fetchScripts)
|
||||
.mockResolvedValueOnce(mockScripts)
|
||||
.mockResolvedValueOnce({});
|
||||
|
||||
render(
|
||||
<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} onRunScript={onRunScript} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("delete-script-build")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click confirm
|
||||
fireEvent.click(screen.getByTitle("Confirm delete"));
|
||||
// Click delete button
|
||||
fireEvent.click(screen.getByTestId("delete-script-build"));
|
||||
|
||||
// Confirm delete
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("confirm-delete-script-build")).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("confirm-delete-script-build"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(removeScript).toHaveBeenCalledWith("build");
|
||||
expect(addToast).toHaveBeenCalledWith("Script 'build' deleted", "success");
|
||||
expect(addToast).toHaveBeenCalledWith("Script deleted", "success");
|
||||
});
|
||||
});
|
||||
|
||||
it("cancels delete when cancel button is clicked", async () => {
|
||||
it("cancels delete when cancel is clicked", async () => {
|
||||
vi.mocked(fetchScripts).mockResolvedValueOnce(mockScripts);
|
||||
|
||||
render(<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} />);
|
||||
render(
|
||||
<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} onRunScript={onRunScript} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("build")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("delete-script-build")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click delete button
|
||||
fireEvent.click(screen.getByTestId("delete-script-build"));
|
||||
|
||||
// Should show confirm/cancel buttons
|
||||
// Cancel delete
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Cancel delete")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("cancel-delete-script-build")).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("cancel-delete-script-build"));
|
||||
|
||||
// Click cancel
|
||||
fireEvent.click(screen.getByTitle("Cancel delete"));
|
||||
|
||||
// Delete should not have been called
|
||||
expect(removeScript).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles API errors gracefully", async () => {
|
||||
vi.mocked(fetchScripts).mockRejectedValueOnce(new Error("Failed to fetch"));
|
||||
|
||||
render(<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} />);
|
||||
|
||||
// Script should still be visible, delete button back to normal
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith("Failed to fetch", "error");
|
||||
expect(screen.getByTestId("delete-script-build")).toBeInTheDocument();
|
||||
expect(removeScript).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows loading state while fetching scripts", async () => {
|
||||
vi.mocked(fetchScripts).mockImplementation(
|
||||
() => new Promise((resolve) => setTimeout(() => resolve({}), 100))
|
||||
it("shows error toast when API call fails", async () => {
|
||||
vi.mocked(fetchScripts).mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
render(
|
||||
<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} onRunScript={onRunScript} />
|
||||
);
|
||||
|
||||
render(<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} />);
|
||||
|
||||
expect(screen.getByTestId("scripts-loading")).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith(expect.stringContaining("Network error"), "error");
|
||||
});
|
||||
});
|
||||
|
||||
it("edits an existing script", async () => {
|
||||
vi.mocked(fetchScripts).mockResolvedValueOnce(mockScripts);
|
||||
vi.mocked(addScript).mockResolvedValueOnce({ build: "npm run build:prod", test: "pnpm test" });
|
||||
it("cancels form when Cancel button is clicked", async () => {
|
||||
vi.mocked(fetchScripts).mockResolvedValueOnce({});
|
||||
|
||||
render(<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} />);
|
||||
render(
|
||||
<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} onRunScript={onRunScript} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("build")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("add-script-btn")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("add-script-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("script-save-btn")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("script-cancel-btn"));
|
||||
|
||||
// Form should be closed, back to list view
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("add-script-btn")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("edits existing script", async () => {
|
||||
vi.mocked(fetchScripts)
|
||||
.mockResolvedValueOnce(mockScripts)
|
||||
.mockResolvedValueOnce(mockScripts);
|
||||
|
||||
render(
|
||||
<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} onRunScript={onRunScript} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("edit-script-build")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click edit button
|
||||
fireEvent.click(screen.getByTestId("edit-script-build"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("script-form")).toBeInTheDocument();
|
||||
expect(screen.getByText("Edit Script")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("script-name-input")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("script-command-input")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Name should be disabled for editing
|
||||
const nameInput = screen.getByTestId("script-name-input") as HTMLInputElement;
|
||||
expect(nameInput.disabled).toBe(true);
|
||||
const commandInput = screen.getByTestId("script-command-input");
|
||||
fireEvent.change(commandInput, { target: { value: "npm run build:prod" } });
|
||||
|
||||
// Change command
|
||||
fireEvent.change(screen.getByTestId("script-command-input"), {
|
||||
target: { value: "npm run build:prod" },
|
||||
});
|
||||
|
||||
// Click save
|
||||
fireEvent.click(screen.getByTestId("save-script-btn"));
|
||||
fireEvent.click(screen.getByTestId("script-save-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addScript).toHaveBeenCalledWith("build", "npm run build:prod");
|
||||
expect(addToast).toHaveBeenCalledWith("Script 'build' updated", "success");
|
||||
expect(addToast).toHaveBeenCalledWith("Script updated", "success");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows validation error when script name is empty", async () => {
|
||||
vi.mocked(fetchScripts)
|
||||
.mockResolvedValueOnce({})
|
||||
.mockResolvedValueOnce({});
|
||||
|
||||
render(
|
||||
<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} onRunScript={onRunScript} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("add-script-btn")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("add-script-btn"));
|
||||
|
||||
const commandInput = screen.getByTestId("script-command-input");
|
||||
fireEvent.change(commandInput, { target: { value: "echo test" } });
|
||||
|
||||
// Try to save with empty name
|
||||
fireEvent.click(screen.getByTestId("script-save-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith("Script name is required", "error");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows validation error when command is empty", async () => {
|
||||
vi.mocked(fetchScripts)
|
||||
.mockResolvedValueOnce({})
|
||||
.mockResolvedValueOnce({});
|
||||
|
||||
render(
|
||||
<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} onRunScript={onRunScript} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("add-script-btn")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("add-script-btn"));
|
||||
|
||||
const nameInput = screen.getByTestId("script-name-input");
|
||||
fireEvent.change(nameInput, { target: { value: "test-script" } });
|
||||
|
||||
// Try to save with empty command
|
||||
fireEvent.click(screen.getByTestId("script-save-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith("Script command is required", "error");
|
||||
});
|
||||
});
|
||||
|
||||
it("handles duplicate script name error", async () => {
|
||||
vi.mocked(fetchScripts)
|
||||
.mockResolvedValueOnce({})
|
||||
.mockResolvedValueOnce({});
|
||||
vi.mocked(addScript).mockRejectedValueOnce(new Error("A script with this name already exists"));
|
||||
|
||||
render(
|
||||
<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} onRunScript={onRunScript} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("add-script-btn")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("add-script-btn"));
|
||||
|
||||
fireEvent.change(screen.getByTestId("script-name-input"), { target: { value: "test-script" } });
|
||||
fireEvent.change(screen.getByTestId("script-command-input"), { target: { value: "echo test" } });
|
||||
|
||||
fireEvent.click(screen.getByTestId("script-save-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith("A script with this name already exists", "error");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5640,6 +5640,210 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Scripts Routes ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Reserved script names that conflict with system commands or existing features */
|
||||
const RESERVED_SCRIPT_NAMES = new Set(["run", "exec", "shell", "bash", "sh"]);
|
||||
|
||||
/**
|
||||
* GET /api/scripts
|
||||
* List all saved scripts from project settings.
|
||||
* Returns: Record<string, string> (name -> command)
|
||||
*/
|
||||
router.get("/scripts", async (_req, res) => {
|
||||
try {
|
||||
const settings = await store.getSettings();
|
||||
res.json(settings.scripts ?? {});
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/scripts
|
||||
* Add or update a script.
|
||||
* Body: { name: string, command: string }
|
||||
* Returns: { name: string, command: string }
|
||||
*/
|
||||
router.post("/scripts", async (req, res) => {
|
||||
try {
|
||||
const { name, command } = req.body;
|
||||
|
||||
// Validate name
|
||||
if (!name || typeof name !== "string" || !name.trim()) {
|
||||
res.status(400).json({ error: "Script name is required" });
|
||||
return;
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
||||
res.status(400).json({ error: "Script name must contain only alphanumeric characters, hyphens, and underscores (no spaces)" });
|
||||
return;
|
||||
}
|
||||
if (RESERVED_SCRIPT_NAMES.has(name.toLowerCase())) {
|
||||
res.status(400).json({ error: `Script name '${name}' is reserved` });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate command
|
||||
if (!command || typeof command !== "string" || !command.trim()) {
|
||||
res.status(400).json({ error: "Script command is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const scriptName = name.trim();
|
||||
const scriptCommand = command.trim();
|
||||
|
||||
// Get current settings
|
||||
const settings = await store.getSettings();
|
||||
const currentScripts = settings.scripts ?? {};
|
||||
|
||||
// Check for duplicate names (409 Conflict)
|
||||
if (currentScripts[scriptName] !== undefined && currentScripts[scriptName] !== scriptCommand) {
|
||||
res.status(409).json({ error: `Script named '${scriptName}' already exists. Use PUT to update or DELETE to remove first.` });
|
||||
return;
|
||||
}
|
||||
|
||||
// Update settings with the new/updated script
|
||||
const updatedScripts = { ...currentScripts, [scriptName]: scriptCommand };
|
||||
await store.updateSettings({ scripts: updatedScripts });
|
||||
|
||||
res.status(201).json({ name: scriptName, command: scriptCommand });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/scripts/:name
|
||||
* Remove a script by name.
|
||||
* Returns: Record<string, string> (remaining scripts)
|
||||
*/
|
||||
router.delete("/scripts/:name", async (req, res) => {
|
||||
try {
|
||||
const scriptName = Array.isArray(req.params.name) ? req.params.name[0] : req.params.name;
|
||||
|
||||
if (!scriptName) {
|
||||
res.status(400).json({ error: "Script name is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(scriptName)) {
|
||||
res.status(400).json({ error: "Script name must contain only alphanumeric characters, hyphens, and underscores (no spaces)" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current settings
|
||||
const settings = await store.getSettings();
|
||||
const currentScripts = settings.scripts ?? {};
|
||||
|
||||
// Check if script exists
|
||||
if (currentScripts[scriptName] === undefined) {
|
||||
res.status(404).json({ error: `Script '${scriptName}' not found` });
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove the script
|
||||
const updatedScripts = { ...currentScripts };
|
||||
delete updatedScripts[scriptName];
|
||||
await store.updateSettings({ scripts: updatedScripts });
|
||||
|
||||
res.status(200).json(updatedScripts);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/scripts/:name/run
|
||||
* Execute a saved script by name using terminal service.
|
||||
* Body: { args?: string[] } - Optional arguments to append to the command
|
||||
* Returns: { sessionId: string, command: string }
|
||||
*/
|
||||
router.post("/scripts/:name/run", async (req, res) => {
|
||||
try {
|
||||
const scriptName = Array.isArray(req.params.name) ? req.params.name[0] : req.params.name;
|
||||
|
||||
if (!scriptName) {
|
||||
res.status(400).json({ error: "Script name is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(scriptName)) {
|
||||
res.status(400).json({ error: "Script name must contain only alphanumeric characters, hyphens, and underscores (no spaces)" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the script from settings
|
||||
const settings = await store.getSettings();
|
||||
const currentScripts = settings.scripts ?? {};
|
||||
|
||||
if (currentScripts[scriptName] === undefined) {
|
||||
res.status(404).json({ error: `Script '${scriptName}' not found` });
|
||||
return;
|
||||
}
|
||||
|
||||
const baseCommand = currentScripts[scriptName];
|
||||
const { args } = req.body ?? {};
|
||||
|
||||
// Validate args if provided
|
||||
if (args !== undefined && !Array.isArray(args)) {
|
||||
res.status(400).json({ error: "args must be an array of strings" });
|
||||
return;
|
||||
}
|
||||
if (args && !args.every((a: unknown) => typeof a === "string")) {
|
||||
res.status(400).json({ error: "args must be an array of strings" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Build the full command with args
|
||||
let fullCommand = baseCommand;
|
||||
if (args && args.length > 0) {
|
||||
// Properly escape arguments for shell execution
|
||||
const escapedArgs = args.map((arg: unknown) => {
|
||||
// Quote and escape the argument for shell
|
||||
const str = String(arg);
|
||||
// If the arg contains special characters, use double quotes with escaping
|
||||
if (str.includes('"') || str.includes("$") || str.includes("`") || str.includes("\\")) {
|
||||
// Use single quotes and escape embedded single quotes
|
||||
return `'${str.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
// Simple case: use double quotes
|
||||
return `"${str}"`;
|
||||
});
|
||||
fullCommand = `${baseCommand} ${escapedArgs.join(" ")}`;
|
||||
}
|
||||
|
||||
// Execute via terminal service
|
||||
const terminalService = getTerminalService(store.getRootDir());
|
||||
const result = await terminalService.createSession({
|
||||
cwd: store.getRootDir(),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
const statusByCode = {
|
||||
max_sessions: 503,
|
||||
invalid_shell: 400,
|
||||
pty_load_failed: 503,
|
||||
pty_spawn_failed: 500,
|
||||
} as const;
|
||||
const status = result.code ? (statusByCode[result.code] ?? 500) : 500;
|
||||
res.status(status).json({ error: result.error || "Failed to create terminal session" });
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = result.session.id;
|
||||
|
||||
// Write the command to the PTY (use writeInput for compatibility with test mocks)
|
||||
terminalService.writeInput(sessionId, `${fullCommand}\n`);
|
||||
|
||||
res.status(201).json({
|
||||
sessionId,
|
||||
command: fullCommand,
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Agent Routes ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -582,6 +582,13 @@ export class TerminalService extends EventEmitter {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for write() for backward compatibility
|
||||
*/
|
||||
writeInput(sessionId: string, data: string): boolean {
|
||||
return this.write(sessionId, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize a terminal session
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user