feat(KB-643): add dashboard scripts management and execution
- Add project settings support and dashboard API helpers for custom scripts - Add scripts CRUD and run routes backed by terminal sessions with validation and duplicate checks - Add Scripts modal and header integration for managing and launching project scripts - Add regression coverage for scripts routes and API client helpers
This commit is contained in:
@@ -28,6 +28,11 @@ import {
|
||||
unregisterProject,
|
||||
fetchProjectHealth,
|
||||
fetchActivityFeed,
|
||||
fetchScripts,
|
||||
addScript,
|
||||
removeScript,
|
||||
runScript,
|
||||
waitForScriptCompletion,
|
||||
pauseProject,
|
||||
resumeProject,
|
||||
fetchFirstRunStatus,
|
||||
@@ -2156,3 +2161,94 @@ describe("fetchProjectConfig", () => {
|
||||
expect(result.rootDir).toBe("/path/to/project");
|
||||
});
|
||||
});
|
||||
|
||||
describe("scripts API helpers", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("fetchScripts uses GET /api/scripts", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { build: "pnpm build" }));
|
||||
|
||||
const result = await fetchScripts();
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/scripts",
|
||||
expect.objectContaining({ headers: { "Content-Type": "application/json" } }),
|
||||
);
|
||||
expect(result).toEqual({ build: "pnpm build" });
|
||||
});
|
||||
|
||||
it("addScript posts the expected payload", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { build: "pnpm build" }, 201));
|
||||
|
||||
await addScript("build", "pnpm build");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/scripts",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: "build", command: "pnpm build" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("removeScript URL-encodes the script name", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, {}, 200));
|
||||
|
||||
await removeScript("build_script");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/scripts/build_script",
|
||||
expect.objectContaining({ method: "DELETE" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("runScript returns the terminal session handle", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { command: "pnpm test", sessionId: "sess-1" }, 201));
|
||||
|
||||
const result = await runScript("test", ["--watch"]);
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/scripts/test/run",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ args: ["--watch"] }),
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ command: "pnpm test", sessionId: "sess-1" });
|
||||
});
|
||||
|
||||
it("waitForScriptCompletion polls until the session finishes", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(mockFetchResponse(true, {
|
||||
id: "sess-1",
|
||||
command: "pnpm test",
|
||||
running: true,
|
||||
exitCode: null,
|
||||
output: "starting",
|
||||
startTime: new Date().toISOString(),
|
||||
}))
|
||||
.mockReturnValueOnce(mockFetchResponse(true, {
|
||||
id: "sess-1",
|
||||
command: "pnpm test",
|
||||
running: false,
|
||||
exitCode: 1,
|
||||
output: "done",
|
||||
startTime: new Date().toISOString(),
|
||||
}));
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const promise = waitForScriptCompletion("sess-1");
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
const result = await promise;
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(result).toEqual({ output: "done", exitCode: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -325,6 +325,8 @@ export function addComment(id: string, text: string): Promise<Task> {
|
||||
});
|
||||
}
|
||||
|
||||
export const addSteeringComment = addComment;
|
||||
|
||||
export function requestSpecRevision(id: string, feedback: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/spec/revise`, {
|
||||
method: "POST",
|
||||
@@ -1342,6 +1344,56 @@ export function fetchWorkflowResults(taskId: string): Promise<WorkflowStepResult
|
||||
return api<WorkflowStepResult[]>(`/tasks/${encodeURIComponent(taskId)}/workflow-results`);
|
||||
}
|
||||
|
||||
// ── Scripts ─────────────────────────────────────────────────────────────
|
||||
|
||||
export type ScriptsMap = Record<string, string>;
|
||||
|
||||
export interface RunScriptResponse {
|
||||
command: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export async function waitForScriptCompletion(sessionId: string): Promise<{ output: string; exitCode: number }> {
|
||||
for (;;) {
|
||||
const session = await getTerminalSession(sessionId);
|
||||
if (!session.running) {
|
||||
return {
|
||||
output: session.output,
|
||||
exitCode: session.exitCode ?? 0,
|
||||
};
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch all project-defined scripts */
|
||||
export function fetchScripts(): Promise<ScriptsMap> {
|
||||
return api<ScriptsMap>("/scripts");
|
||||
}
|
||||
|
||||
/** Create a new project-defined script */
|
||||
export async function addScript(name: string, command: string): Promise<void> {
|
||||
await api<ScriptsMap>("/scripts", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, command }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove a project-defined script */
|
||||
export async function removeScript(name: string): Promise<void> {
|
||||
await api<ScriptsMap>(`/scripts/${encodeURIComponent(name)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
/** Execute a script via the terminal service and return the created session */
|
||||
export function runScript(name: string, args?: string[]): Promise<RunScriptResponse> {
|
||||
return api<RunScriptResponse>(`/scripts/${encodeURIComponent(name)}/run`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ args }),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Workflow Step Templates ──────────────────────────────────────────────
|
||||
|
||||
/** Re-export WorkflowStepTemplate type from core */
|
||||
@@ -1922,38 +1974,3 @@ export function fetchTaskDiff(taskId: string): Promise<TaskDiff> {
|
||||
return api<TaskDiff>(`/tasks/${encodeURIComponent(taskId)}/diff`);
|
||||
}
|
||||
|
||||
// ── Scripts API ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Script execution result */
|
||||
export interface ScriptRunResult {
|
||||
output: string;
|
||||
exitCode: number;
|
||||
}
|
||||
|
||||
/** Fetch all project-defined 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 by name */
|
||||
export function removeScript(name: string): Promise<Record<string, string>> {
|
||||
return api<Record<string, string>>(`/scripts/${encodeURIComponent(name)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
/** Execute a script with optional arguments */
|
||||
export function runScript(name: string, args?: string[]): Promise<ScriptRunResult> {
|
||||
return api<ScriptRunResult>(`/scripts/${encodeURIComponent(name)}/run`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ args }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -537,24 +537,6 @@ export function Header({
|
||||
<Clock size={16} />
|
||||
<span>Scheduled Tasks</span>
|
||||
</button>
|
||||
<button
|
||||
className="mobile-overflow-item"
|
||||
onClick={() => handleOverflowAction(onToggleTerminal)}
|
||||
role="menuitem"
|
||||
data-testid="overflow-terminal-btn"
|
||||
>
|
||||
<Terminal size={16} />
|
||||
<span>Open Terminal</span>
|
||||
</button>
|
||||
<button
|
||||
className="mobile-overflow-item"
|
||||
onClick={() => handleOverflowAction(onOpenSchedules)}
|
||||
role="menuitem"
|
||||
data-testid="overflow-schedules-btn"
|
||||
>
|
||||
<Clock size={16} />
|
||||
<span>Scheduled Tasks</span>
|
||||
</button>
|
||||
<button
|
||||
className="mobile-overflow-item"
|
||||
onClick={() => handleOverflowAction(onOpenSettings)}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
fetchScripts,
|
||||
addScript,
|
||||
removeScript,
|
||||
type ScriptRunResult,
|
||||
} from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
|
||||
@@ -5968,6 +5968,22 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
|
||||
// ── Scripts Routes ─────────────────────────────────────────────────────────
|
||||
|
||||
const SCRIPT_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
||||
const RESERVED_SCRIPT_NAMES = new Set(["run", "list", "add", "remove", "delete", "help"]);
|
||||
|
||||
function validateScriptName(name: string, label = "Script name"): string | null {
|
||||
if (!name.trim()) {
|
||||
return `${label} is required`;
|
||||
}
|
||||
if (!SCRIPT_NAME_PATTERN.test(name)) {
|
||||
return `${label} must be alphanumeric with hyphens and underscores only (no spaces)`;
|
||||
}
|
||||
if (label === "Script name" && RESERVED_SCRIPT_NAMES.has(name.toLowerCase())) {
|
||||
return `Script name '${name}' is reserved`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/scripts
|
||||
* Returns all project-defined scripts from settings.
|
||||
@@ -5984,16 +6000,15 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
|
||||
/**
|
||||
* POST /api/scripts
|
||||
* Add or update a script.
|
||||
* Create a new script.
|
||||
* Body: { name: string, command: string }
|
||||
* Validates name (alphanumeric, hyphens, underscores only, no spaces).
|
||||
* Returns: Record<string, string> (updated scripts)
|
||||
*/
|
||||
router.post("/scripts", async (req, res) => {
|
||||
try {
|
||||
const { name, command } = req.body;
|
||||
const { name, command } = req.body ?? {};
|
||||
|
||||
// Validate name
|
||||
if (!name || typeof name !== "string" || !name.trim()) {
|
||||
res.status(400).json({ error: "name is required" });
|
||||
return;
|
||||
@@ -6005,29 +6020,20 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
|
||||
const trimmedName = name.trim();
|
||||
const trimmedCommand = command.trim();
|
||||
|
||||
// Validate script name format (alphanumeric, hyphens, underscores only)
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(trimmedName)) {
|
||||
res.status(400).json({
|
||||
error: "Script name must be alphanumeric with hyphens and underscores only (no spaces)",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for reserved/conflicting names
|
||||
const reservedNames = ["run", "list", "add", "remove", "delete", "help"];
|
||||
if (reservedNames.includes(trimmedName.toLowerCase())) {
|
||||
res.status(400).json({ error: `Script name '${trimmedName}' is reserved` });
|
||||
const nameValidationError = validateScriptName(trimmedName);
|
||||
if (nameValidationError) {
|
||||
res.status(400).json({ error: nameValidationError });
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = await store.getSettings();
|
||||
const currentScripts = settings.scripts || {};
|
||||
|
||||
// Check if script already exists (for conflict detection)
|
||||
const exists = trimmedName in currentScripts;
|
||||
if (trimmedName in currentScripts) {
|
||||
res.status(409).json({ error: `Script '${trimmedName}' already exists` });
|
||||
return;
|
||||
}
|
||||
|
||||
// Update scripts
|
||||
const updatedScripts = {
|
||||
...currentScripts,
|
||||
[trimmedName]: trimmedCommand,
|
||||
@@ -6035,7 +6041,7 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
|
||||
await store.updateSettings({ scripts: updatedScripts });
|
||||
|
||||
res.status(exists ? 200 : 201).json(updatedScripts);
|
||||
res.status(201).json(updatedScripts);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
@@ -6055,16 +6061,23 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedName = name.trim();
|
||||
const nameValidationError = validateScriptName(trimmedName);
|
||||
if (nameValidationError) {
|
||||
res.status(400).json({ error: nameValidationError });
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = await store.getSettings();
|
||||
const currentScripts = settings.scripts || {};
|
||||
|
||||
if (!(name in currentScripts)) {
|
||||
res.status(404).json({ error: `Script '${name}' not found` });
|
||||
if (!(trimmedName in currentScripts)) {
|
||||
res.status(404).json({ error: `Script '${trimmedName}' not found` });
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove the script
|
||||
const { [name]: _removed, ...remainingScripts } = currentScripts;
|
||||
const { [trimmedName]: _removed, ...remainingScripts } = currentScripts;
|
||||
|
||||
await store.updateSettings({ scripts: remainingScripts });
|
||||
|
||||
@@ -6076,21 +6089,27 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
|
||||
/**
|
||||
* POST /api/scripts/:name/run
|
||||
* Execute a script with optional args.
|
||||
* Execute a project script by creating a terminal session for the resolved command.
|
||||
* Body: { args?: string[] }
|
||||
* Returns: { output: string; exitCode: number }
|
||||
* Returns: { command: string; sessionId: string }
|
||||
*/
|
||||
router.post("/scripts/:name/run", async (req, res) => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
const { args } = req.body;
|
||||
const { args } = req.body ?? {};
|
||||
|
||||
if (!name || !name.trim()) {
|
||||
res.status(400).json({ error: "Script name is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate args if provided
|
||||
const trimmedName = name.trim();
|
||||
const nameValidationError = validateScriptName(trimmedName);
|
||||
if (nameValidationError) {
|
||||
res.status(400).json({ error: nameValidationError });
|
||||
return;
|
||||
}
|
||||
|
||||
if (args !== undefined && !Array.isArray(args)) {
|
||||
res.status(400).json({ error: "args must be an array of strings" });
|
||||
return;
|
||||
@@ -6102,46 +6121,34 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
|
||||
const settings = await store.getSettings();
|
||||
const scripts = settings.scripts || {};
|
||||
const command = scripts[name];
|
||||
const command = scripts[trimmedName];
|
||||
|
||||
if (!command) {
|
||||
res.status(404).json({ error: `Script '${name}' not found` });
|
||||
res.status(404).json({ error: `Script '${trimmedName}' not found` });
|
||||
return;
|
||||
}
|
||||
|
||||
// Build the full command with args
|
||||
const sanitizedArgs = (args || [])
|
||||
.map((arg: string) => arg.replace(/["\\]/g, "\\$&"))
|
||||
.join(" ");
|
||||
const fullCommand = sanitizedArgs ? `${command} ${sanitizedArgs}` : command;
|
||||
const quotedArgs = (args || []).map((arg: string) => JSON.stringify(arg)).join(" ");
|
||||
const fullCommand = quotedArgs ? `${command} ${quotedArgs}` : command;
|
||||
|
||||
// Execute the command using terminal service or execSync
|
||||
const rootDir = store.getRootDir();
|
||||
let output: string;
|
||||
let exitCode: number;
|
||||
const terminalService = getTerminalService(rootDir);
|
||||
const ptySession = await terminalService.createSession({ cwd: rootDir });
|
||||
|
||||
try {
|
||||
// Use execSync for synchronous execution
|
||||
output = execSync(fullCommand, {
|
||||
encoding: "utf-8",
|
||||
timeout: 300000, // 5 minute timeout
|
||||
cwd: rootDir,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
exitCode = 0;
|
||||
} catch (execErr: any) {
|
||||
// Command failed or timed out
|
||||
output = execErr.stdout || "";
|
||||
if (execErr.stderr) {
|
||||
output += (output ? "\n" : "") + execErr.stderr;
|
||||
}
|
||||
if (execErr.message && !execErr.stderr) {
|
||||
output += (output ? "\n" : "") + execErr.message;
|
||||
}
|
||||
exitCode = execErr.status || 1;
|
||||
if (!ptySession.success) {
|
||||
const statusByCode = {
|
||||
max_sessions: 503,
|
||||
invalid_shell: 400,
|
||||
pty_load_failed: 503,
|
||||
pty_spawn_failed: 500,
|
||||
} as const;
|
||||
res.status(statusByCode[ptySession.code]).json({ error: ptySession.error });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ output: output.trim(), exitCode });
|
||||
terminalService.write(ptySession.session.id, `${fullCommand}\n`);
|
||||
|
||||
res.status(201).json({ command: fullCommand, sessionId: ptySession.session.id });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
|
||||
320
packages/dashboard/src/scripts-routes.routes.test.ts
Normal file
320
packages/dashboard/src/scripts-routes.routes.test.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import express from "express";
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
import { createApiRoutes } from "./routes.js";
|
||||
import { request as performRequest, get as performGet } from "./test-request.js";
|
||||
|
||||
function createMockGlobalSettingsStore() {
|
||||
return {
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
updateSettings: vi.fn().mockResolvedValue({}),
|
||||
getSettingsPath: vi.fn().mockReturnValue("/fake/home/.pi/kb/settings.json"),
|
||||
init: vi.fn().mockResolvedValue(false),
|
||||
};
|
||||
}
|
||||
|
||||
function createMockMissionStore() {
|
||||
return {
|
||||
createSession: vi.fn().mockResolvedValue({ id: "session-1", status: "active" }),
|
||||
getSession: vi.fn().mockResolvedValue({ id: "session-1", status: "active", answers: [] }),
|
||||
updateSession: vi.fn().mockResolvedValue(undefined),
|
||||
addAnswer: vi.fn().mockResolvedValue(undefined),
|
||||
deleteSession: vi.fn().mockResolvedValue(undefined),
|
||||
listSessions: vi.fn().mockResolvedValue([]),
|
||||
generatePlan: vi.fn().mockResolvedValue({ plan: "Test plan", steps: [] }),
|
||||
};
|
||||
}
|
||||
|
||||
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
return {
|
||||
getTask: vi.fn(),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
createTask: vi.fn(),
|
||||
moveTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
unarchiveTask: vi.fn(),
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
updateSettings: vi.fn(),
|
||||
updateGlobalSettings: vi.fn(),
|
||||
getSettingsByScope: vi.fn().mockResolvedValue({ global: {}, project: {} }),
|
||||
getGlobalSettingsStore: vi.fn().mockReturnValue(createMockGlobalSettingsStore()),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
getAgentLogs: vi.fn().mockResolvedValue([]),
|
||||
addComment: vi.fn(),
|
||||
addTaskComment: vi.fn(),
|
||||
updateTaskComment: vi.fn(),
|
||||
deleteTaskComment: vi.fn(),
|
||||
updatePrInfo: vi.fn().mockResolvedValue(undefined),
|
||||
updateIssueInfo: vi.fn().mockResolvedValue(undefined),
|
||||
getRootDir: vi.fn().mockReturnValue("/fake/root"),
|
||||
listWorkflowSteps: vi.fn().mockResolvedValue([]),
|
||||
createWorkflowStep: vi.fn(),
|
||||
getWorkflowStep: vi.fn(),
|
||||
updateWorkflowStep: vi.fn(),
|
||||
deleteWorkflowStep: vi.fn(),
|
||||
getMissionStore: vi.fn().mockReturnValue(createMockMissionStore()),
|
||||
...overrides,
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
|
||||
async function GET(app: express.Express, path: string): Promise<{ status: number; body: any }> {
|
||||
const res = await performGet(app, path);
|
||||
return { status: res.status, body: res.body };
|
||||
}
|
||||
|
||||
async function REQUEST(
|
||||
app: express.Express,
|
||||
method: string,
|
||||
path: string,
|
||||
body?: Buffer | string,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<{ status: number; body: any }> {
|
||||
const res = await performRequest(app, method, path, body, headers);
|
||||
return { status: res.status, body: res.body };
|
||||
}
|
||||
|
||||
describe("Scripts routes", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore();
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("returns all scripts from project settings", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
scripts: { build: "pnpm build", test: "pnpm test" },
|
||||
});
|
||||
|
||||
const res = await GET(buildApp(), "/api/scripts");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ build: "pnpm build", test: "pnpm test" });
|
||||
});
|
||||
|
||||
it("creates a new script and returns 201", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ scripts: { test: "pnpm test" } });
|
||||
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce(undefined);
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/scripts",
|
||||
JSON.stringify({ name: "build", command: "pnpm build" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(store.updateSettings).toHaveBeenCalledWith({
|
||||
scripts: { test: "pnpm test", build: "pnpm build" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 409 when creating a duplicate script", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ scripts: { build: "pnpm build" } });
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/scripts",
|
||||
JSON.stringify({ name: "build", command: "pnpm build --filter app" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.error).toContain("already exists");
|
||||
expect(store.updateSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 for invalid script names", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/scripts",
|
||||
JSON.stringify({ name: "bad name", command: "echo hi" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("alphanumeric");
|
||||
});
|
||||
|
||||
it("returns 400 for reserved script names", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/scripts",
|
||||
JSON.stringify({ name: "run", command: "echo hi" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("reserved");
|
||||
});
|
||||
|
||||
it("returns 400 when command is missing", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/scripts",
|
||||
JSON.stringify({ name: "build", command: " " }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("command is required");
|
||||
});
|
||||
|
||||
|
||||
it("deletes an existing script and persists remaining scripts", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
scripts: { build: "pnpm build", test: "pnpm test" },
|
||||
});
|
||||
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce(undefined);
|
||||
|
||||
const res = await REQUEST(buildApp(), "DELETE", "/api/scripts/build");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ test: "pnpm test" });
|
||||
expect(store.updateSettings).toHaveBeenCalledWith({ scripts: { test: "pnpm test" } });
|
||||
});
|
||||
|
||||
it("returns 400 when deleting an invalid script name", async () => {
|
||||
const res = await REQUEST(buildApp(), "DELETE", "/api/scripts/bad%20name");
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("alphanumeric");
|
||||
});
|
||||
|
||||
it("returns 404 when deleting a missing script", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ scripts: { test: "pnpm test" } });
|
||||
|
||||
const res = await REQUEST(buildApp(), "DELETE", "/api/scripts/build");
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toContain("not found");
|
||||
});
|
||||
|
||||
it("returns 400 when running an invalid script name", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/scripts/bad%20name/run",
|
||||
JSON.stringify({ args: [] }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("alphanumeric");
|
||||
});
|
||||
|
||||
it("returns 404 when running a missing script", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ scripts: { test: "pnpm test" } });
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/scripts/build/run",
|
||||
JSON.stringify({ args: [] }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toContain("not found");
|
||||
});
|
||||
|
||||
it("returns 400 when run args are not an array", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ scripts: { test: "pnpm test" } });
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/scripts/test/run",
|
||||
JSON.stringify({ args: "--ok" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("array of strings");
|
||||
});
|
||||
|
||||
it("returns 400 when run args are not an array of strings", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ scripts: { test: "pnpm test" } });
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/scripts/test/run",
|
||||
JSON.stringify({ args: ["--ok", 123] }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("array of strings");
|
||||
});
|
||||
|
||||
it("returns terminal service errors when session creation fails", async () => {
|
||||
const createSessionSpy = vi
|
||||
.spyOn(await import("./terminal-service.js"), "getTerminalService")
|
||||
.mockReturnValue({
|
||||
createSession: vi.fn().mockResolvedValue({
|
||||
success: false,
|
||||
code: "max_sessions",
|
||||
error: "Maximum terminal sessions reached",
|
||||
}),
|
||||
} as any);
|
||||
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ scripts: { test: "pnpm test" } });
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/scripts/test/run",
|
||||
JSON.stringify({ args: [] }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body.error).toContain("Maximum terminal sessions reached");
|
||||
createSessionSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("creates a terminal session in the project root when running a script", async () => {
|
||||
const writeInput = vi.fn();
|
||||
const createSession = vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
session: { id: "pty-123", cwd: "/fake/root", shell: "/bin/zsh" },
|
||||
});
|
||||
const terminalServiceSpy = vi
|
||||
.spyOn(await import("./terminal-service.js"), "getTerminalService")
|
||||
.mockReturnValue({ createSession, writeInput } as any);
|
||||
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ scripts: { test: "pnpm test" } });
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/scripts/test/run",
|
||||
JSON.stringify({ args: ["--filter", "web app; rm -rf /"] }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.sessionId).toBe("pty-123");
|
||||
expect(res.body.command).toBe('pnpm test "--filter" "web app; rm -rf /"');
|
||||
expect(createSession).toHaveBeenCalledWith({ cwd: "/fake/root" });
|
||||
expect(writeInput).toHaveBeenCalledWith("pty-123", 'pnpm test "--filter" "web app; rm -rf /"\n');
|
||||
terminalServiceSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user