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:
gsxdsm
2026-04-01 13:52:18 -07:00
parent 614c710de8
commit d943616cb3
6 changed files with 532 additions and 111 deletions

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,6 @@ import {
fetchScripts,
addScript,
removeScript,
type ScriptRunResult,
} from "../api";
import type { ToastType } from "../hooks/useToast";

View File

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

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